From 6a1d2c28882f7f6972d1dddc2f35c6eb069df693 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 <72802712+sundasnoreen12@users.noreply.github.com> Date: Fri, 25 Oct 2024 12:49:55 +0500 Subject: [PATCH] feat: converted notification redux structure to context API (#598) * feat: converted notification redux structure to context API * fix: fixed more notification show button issue * fix: fixed naming and restructure issue * fix: renamed component * refactor: rmeoved alt attribute from button * fix: fixed disable next line lint issue * fix: fixed UI placement issue * test: fix test cases * fix: refactor useref code * refactor: refactor learningHeader component * fix: fix test case of learningheader * fix: refactor changes --- .env.development | 1 + .env.test | 1 + .eslintrc.js | 6 +- package-lock.json | 460 +++++++----------- package.json | 5 +- .../__factories__/notifications.factory.js | 3 +- src/Notifications/data/selectors.js | 2 + src/Notifications/data/slice.js | 3 + src/Notifications/data/thunks.js | 4 +- src/learning-header/AuthenticatedUser.jsx | 76 +++ .../AuthenticatedUserDropdown.jsx | 8 +- src/learning-header/LearningHeader.jsx | 19 +- .../New-AuthenticatedUserDropdown.jsx | 108 ++++ .../NotificationEmptySection.jsx | 42 ++ src/new-notifications/NotificationRowItem.jsx | 96 ++++ .../NotificationSections.jsx | 136 ++++++ src/new-notifications/NotificationTabs.jsx | 67 +++ .../context/notificationPopoverContext.js | 5 + .../context/notificationsContext.js | 17 + .../data/__factories__/index.js | 1 + .../__factories__/notifications.factory.js | 32 ++ src/new-notifications/data/api.js | 37 ++ src/new-notifications/data/api.test.js | 147 ++++++ src/new-notifications/data/constants.js | 13 + src/new-notifications/data/hook.js | 203 ++++++++ src/new-notifications/index.jsx | 231 +++++++++ src/new-notifications/index.test.jsx | 136 ++++++ src/new-notifications/messages.js | 66 +++ src/new-notifications/notification.scss | 228 +++++++++ .../notificationRowItem.test.jsx | 91 ++++ .../notificationSections.test.jsx | 141 ++++++ .../notificationTabs.test.jsx | 103 ++++ src/new-notifications/test-utils.js | 32 ++ .../tours/NotificationTour.jsx | 30 ++ src/new-notifications/tours/constants.js | 19 + src/new-notifications/tours/data/api.js | 15 + src/new-notifications/tours/data/hooks.js | 74 +++ src/new-notifications/tours/messages.js | 26 + src/new-notifications/utils.js | 63 +++ src/setupTest.js | 2 + 40 files changed, 2435 insertions(+), 314 deletions(-) create mode 100644 src/learning-header/AuthenticatedUser.jsx create mode 100644 src/learning-header/New-AuthenticatedUserDropdown.jsx create mode 100644 src/new-notifications/NotificationEmptySection.jsx create mode 100644 src/new-notifications/NotificationRowItem.jsx create mode 100644 src/new-notifications/NotificationSections.jsx create mode 100644 src/new-notifications/NotificationTabs.jsx create mode 100644 src/new-notifications/context/notificationPopoverContext.js create mode 100644 src/new-notifications/context/notificationsContext.js create mode 100644 src/new-notifications/data/__factories__/index.js create mode 100644 src/new-notifications/data/__factories__/notifications.factory.js create mode 100644 src/new-notifications/data/api.js create mode 100644 src/new-notifications/data/api.test.js create mode 100644 src/new-notifications/data/constants.js create mode 100644 src/new-notifications/data/hook.js create mode 100644 src/new-notifications/index.jsx create mode 100644 src/new-notifications/index.test.jsx create mode 100644 src/new-notifications/messages.js create mode 100644 src/new-notifications/notification.scss create mode 100644 src/new-notifications/notificationRowItem.test.jsx create mode 100644 src/new-notifications/notificationSections.test.jsx create mode 100644 src/new-notifications/notificationTabs.test.jsx create mode 100644 src/new-notifications/test-utils.js create mode 100644 src/new-notifications/tours/NotificationTour.jsx create mode 100644 src/new-notifications/tours/constants.js create mode 100644 src/new-notifications/tours/data/api.js create mode 100644 src/new-notifications/tours/data/hooks.js create mode 100644 src/new-notifications/tours/messages.js create mode 100644 src/new-notifications/utils.js diff --git a/.env.development b/.env.development index 02514cea..53605820 100644 --- a/.env.development +++ b/.env.development @@ -21,3 +21,4 @@ LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/prod/logo-trademark.svg LOGO_WHITE_URL=https://edx-cdn.org/v3/prod/logo-white.svg FAVICON_URL=https://edx-cdn.org/v3/prod/favicon.ico NOTIFICATION_FEEDBACK_URL='' +CAREERS_URL='' diff --git a/.env.test b/.env.test index a3cae6d1..335fa225 100644 --- a/.env.test +++ b/.env.test @@ -21,3 +21,4 @@ LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/prod/logo-trademark.svg LOGO_WHITE_URL=https://edx-cdn.org/v3/prod/logo-white.svg FAVICON_URL=https://edx-cdn.org/v3/prod/favicon.ico NOTIFICATION_FEEDBACK_URL='' +CAREERS_URL='' diff --git a/.eslintrc.js b/.eslintrc.js index b4e44031..ee5c841b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,8 @@ // eslint-disable-next-line import/no-extraneous-dependencies const { createConfig } = require('@openedx/frontend-build'); -module.exports = createConfig('eslint'); +module.exports = createConfig('eslint', { + globals: { + lightningjs: true, + }, +}); diff --git a/package-lock.json b/package-lock.json index f2f33c3b..88d9f757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "babel-polyfill": "6.26.0", "classnames": "2.5.1", "core-js": "3.37.1", + "dompurify": "^3.1.7", "lodash": "4.17.21", "react-redux": "7.2.9", "react-responsive": "^10.0.0", @@ -46,7 +47,6 @@ "react": "17.0.2", "react-dom": "17.0.2", "react-redux": "7.2.9", - "react-router-dom": "^6.25.1", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "redux": "4.2.1", @@ -57,7 +57,8 @@ "@openedx/paragon": "^21.11.0", "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", - "react-dom": "^16.9.0 || ^17.0.0" + "react-dom": "^16.9.0 || ^17.0.0", + "react-router-dom": "^6.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -84,9 +85,7 @@ } }, "node_modules/@babel/cli": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.24.8.tgz", - "integrity": "sha512-isdp+G6DpRyKc+3Gqxy2rjzgF7Zj9K0mzLNnxz+E/fgeag8qT3vVulX4gY9dGO1q0y+0lUv6V3a+uhUzMzrwXg==", + "version": "7.24.7", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", @@ -117,12 +116,10 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", - "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.25.7", + "@babel/highlight": "^7.24.7", "picocolors": "^1.0.0" }, "engines": { @@ -130,30 +127,26 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.8.tgz", - "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==", + "version": "7.24.7", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", - "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", + "version": "7.24.7", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.9", - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-module-transforms": "^7.24.9", - "@babel/helpers": "^7.24.8", - "@babel/parser": "^7.24.8", + "@babel/generator": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helpers": "^7.24.7", + "@babel/parser": "^7.24.7", "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.9", + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -189,27 +182,23 @@ } }, "node_modules/@babel/generator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", - "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7", + "@babel/types": "^7.24.7", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" + "jsesc": "^2.5.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -227,14 +216,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", - "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.7", - "@babel/helper-validator-option": "^7.25.7", - "browserslist": "^4.24.0", + "@babel/compat-data": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", + "browserslist": "^4.22.2", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -324,41 +311,36 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.7.tgz", - "integrity": "sha512-O31Ssjd5K6lPbTX9AAYpSKrZmLeagt9uwschJd+Ixo6QiRyfpvgtVQp8qrDR9UNFjZ8+DO34ZkdrN+BnPXemeA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.7.tgz", - "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.7.tgz", - "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.7", - "@babel/helper-simple-access": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -368,21 +350,17 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.7.tgz", - "integrity": "sha512-VAwcwuYhv/AT+Vfr28c9y6SHzTan1ryqrydSTFGjU0uDJHw3uZ+PduI8plCLkRsDnqK2DMEDmwrOQRsK/Ykjng==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", - "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", + "version": "7.24.7", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -404,14 +382,12 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.7.tgz", - "integrity": "sha512-iy8JhqlUW9PtZkd4pHM96v6BdJ66Ba9yWSE4z0W4TvSZwLBPkyDsiIU3ENe4SmrzRBs76F7rQXTy1lYC49n6Lw==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.7", - "@babel/helper-optimise-call-expression": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-member-expression-to-functions": "^7.24.7", + "@babel/helper-optimise-call-expression": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -421,26 +397,22 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", - "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.7.tgz", - "integrity": "sha512-pPbNbchZBkPMD50K0p3JGcFMNLVUCuU/ABybm/PGNj4JiHrpmNyqqCphBk4i19xXtNV0JhldQJJtbSW5aUvbyA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -457,27 +429,21 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", - "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", + "version": "7.24.7", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", - "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "version": "7.24.7", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", - "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", + "version": "7.24.7", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -497,25 +463,21 @@ } }, "node_modules/@babel/helpers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", - "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/template": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", - "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.7", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" @@ -526,8 +488,6 @@ }, "node_modules/@babel/highlight/node_modules/ansi-styles": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "license": "MIT", "dependencies": { "color-convert": "^1.9.0" @@ -538,8 +498,6 @@ }, "node_modules/@babel/highlight/node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", @@ -552,8 +510,6 @@ }, "node_modules/@babel/highlight/node_modules/color-convert": { "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "license": "MIT", "dependencies": { "color-name": "1.1.3" @@ -561,14 +517,10 @@ }, "node_modules/@babel/highlight/node_modules/color-name": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "license": "MIT" }, "node_modules/@babel/highlight/node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "license": "MIT", "engines": { "node": ">=0.8.0" @@ -576,8 +528,6 @@ }, "node_modules/@babel/highlight/node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "license": "MIT", "engines": { "node": ">=4" @@ -585,8 +535,6 @@ }, "node_modules/@babel/highlight/node_modules/supports-color": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "license": "MIT", "dependencies": { "has-flag": "^3.0.0" @@ -596,13 +544,8 @@ } }, "node_modules/@babel/parser": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", - "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "version": "7.24.7", "license": "MIT", - "dependencies": { - "@babel/types": "^7.25.8" - }, "bin": { "parser": "bin/babel-parser.js" }, @@ -1042,16 +985,16 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.7.tgz", - "integrity": "sha512-9j9rnl+YCQY0IGoeipXvnk3niWicIB6kCsWRGLwX241qSXpbA4MKxtp/EdvFxsc4zI5vqfLxzOd0twIJ7I99zg==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-replace-supers": "^7.25.7", - "@babel/traverse": "^7.25.7", + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-replace-supers": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", "globals": "^11.1.0" }, "engines": { @@ -1076,12 +1019,10 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.7.tgz", - "integrity": "sha512-xKcfLTlJYUczdaM1+epcdh1UGewJqr9zATgrNHcLBcV2QmfvPPEixo/sK/syql9cEmbr7ulu5HMFG5vbbt/sEA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1257,14 +1198,12 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.7.tgz", - "integrity": "sha512-L9Gcahi0kKFYXvweO6n0wc3ZG1ChpSFdgG+eV1WYZ3/dGbJK7vvk91FgGgak8YwRgrCuihF8tE/Xg07EkL5COg==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-simple-access": "^7.25.7" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1403,13 +1342,12 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.8.tgz", - "integrity": "sha512-q05Bk7gXOxpTHoQ8RSzGSh/LHVB9JEIkKnk3myAWwZHnYiTGYtbdrYkIsS8Xyh4ltKf7GNUSgzs/6P2bJtBAQg==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, "engines": { "node": ">=6.9.0" @@ -1625,12 +1563,10 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.7.tgz", - "integrity": "sha512-OmWmQtTHnO8RSUbL0NTdtpbZHeNTnm68Gj5pA4Y2blFNh+V4iZR68V1qL9cI37J21ZN7AaCnkfdHtLExQPf2uA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1711,15 +1647,13 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.8.tgz", - "integrity": "sha512-vObvMZB6hNWuDxhSaEPTKCwcqkAIuDtE+bQGn4XMXne1DSLzFVY8Vmj1bm+mUQXYNN8NmaQEO+r8MMbzPr1jBQ==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.24.8", - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-validator-option": "^7.24.8", + "@babel/compat-data": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.24.7", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.24.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", @@ -1750,9 +1684,9 @@ "@babel/plugin-transform-block-scoping": "^7.24.7", "@babel/plugin-transform-class-properties": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.24.7", - "@babel/plugin-transform-classes": "^7.24.8", + "@babel/plugin-transform-classes": "^7.24.7", "@babel/plugin-transform-computed-properties": "^7.24.7", - "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-destructuring": "^7.24.7", "@babel/plugin-transform-dotall-regex": "^7.24.7", "@babel/plugin-transform-duplicate-keys": "^7.24.7", "@babel/plugin-transform-dynamic-import": "^7.24.7", @@ -1765,7 +1699,7 @@ "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-member-expression-literals": "^7.24.7", "@babel/plugin-transform-modules-amd": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", "@babel/plugin-transform-modules-systemjs": "^7.24.7", "@babel/plugin-transform-modules-umd": "^7.24.7", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", @@ -1775,7 +1709,7 @@ "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-object-super": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-optional-chaining": "^7.24.7", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", @@ -1786,7 +1720,7 @@ "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-template-literals": "^7.24.7", - "@babel/plugin-transform-typeof-symbol": "^7.24.8", + "@babel/plugin-transform-typeof-symbol": "^7.24.7", "@babel/plugin-transform-unicode-escapes": "^7.24.7", "@babel/plugin-transform-unicode-property-regex": "^7.24.7", "@babel/plugin-transform-unicode-regex": "^7.24.7", @@ -1795,7 +1729,7 @@ "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.10.4", "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.37.1", + "core-js-compat": "^3.31.0", "semver": "^6.3.1" }, "engines": { @@ -1878,30 +1812,29 @@ } }, "node_modules/@babel/template": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", - "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", - "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.7", - "@babel/generator": "^7.25.7", - "@babel/parser": "^7.25.7", - "@babel/template": "^7.25.7", - "@babel/types": "^7.25.7", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.24.7", + "@babel/helper-environment-visitor": "^7.24.7", + "@babel/helper-function-name": "^7.24.7", + "@babel/helper-hoist-variables": "^7.24.7", + "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/parser": "^7.24.7", + "@babel/types": "^7.24.7", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1910,13 +1843,11 @@ } }, "node_modules/@babel/types": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", - "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", + "version": "7.24.7", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", + "@babel/helper-string-parser": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, "engines": { @@ -1935,9 +1866,7 @@ } }, "node_modules/@csstools/cascade-layer-name-parser": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.13.tgz", - "integrity": "sha512-MX0yLTwtZzr82sQ0zOjqimpZbzjMaK/h2pmlrLK7DCzlmiZLYFpoO94WmN1akRVo6ll/TdpHb53vihHLUMyvng==", + "version": "1.0.11", "funding": [ { "type": "github", @@ -1953,14 +1882,12 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.7.1", - "@csstools/css-tokenizer": "^2.4.1" + "@csstools/css-parser-algorithms": "^2.6.3", + "@csstools/css-tokenizer": "^2.3.1" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", - "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", + "version": "2.6.3", "funding": [ { "type": "github", @@ -1976,13 +1903,11 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.4.1" + "@csstools/css-tokenizer": "^2.3.1" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", - "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", + "version": "2.3.1", "funding": [ { "type": "github", @@ -1999,9 +1924,7 @@ } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.13", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", - "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", + "version": "2.1.11", "funding": [ { "type": "github", @@ -2017,8 +1940,8 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.7.1", - "@csstools/css-tokenizer": "^2.4.1" + "@csstools/css-parser-algorithms": "^2.6.3", + "@csstools/css-tokenizer": "^2.3.1" } }, "node_modules/@discoveryjs/json-ext": { @@ -2040,9 +1963,7 @@ "license": "AGPL-3.0" }, "node_modules/@edx/eslint-config": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-4.2.0.tgz", - "integrity": "sha512-2wuIw49uyj6gRwS74qJ8WhBU+X2FOP4uot40sthIC4YU9qCM7WJOcOuAhkRPP1FvZKd3UQH3gZM7eJ85xzDBqA==", + "version": "4.1.0", "license": "MIT", "peerDependencies": { "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -2183,9 +2104,7 @@ } }, "node_modules/@edx/typescript-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@edx/typescript-config/-/typescript-config-1.1.0.tgz", - "integrity": "sha512-HF+7dsSgA2YQ6f/qV4HnrEYBoIhIdxVQZgDyYk/YGvaVGqT6IFuaHnYUP7ImpCUMOUmx/Jl7EyuVeaMe2LrMcA==", + "version": "1.0.1", "license": "MIT", "peerDependencies": { "typescript": "^4.9.4" @@ -3212,22 +3131,20 @@ } }, "node_modules/@openedx/frontend-build": { - "version": "14.1.5", - "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.1.5.tgz", - "integrity": "sha512-QEdl55jNitdQL7RDAuX/EgfxsyBeEZfW3fc9Df4Py5KY6NKjRE7wNLeBMxYCFagEgXwaR1Btiw5NxzByAdlnfg==", + "version": "14.0.10", "license": "AGPL-3.0", "dependencies": { - "@babel/cli": "7.24.8", - "@babel/core": "7.24.9", + "@babel/cli": "7.24.7", + "@babel/core": "7.24.7", "@babel/eslint-parser": "7.22.9", "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-object-rest-spread": "7.20.7", "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/preset-env": "7.24.8", + "@babel/preset-env": "7.24.7", "@babel/preset-react": "7.24.7", - "@edx/eslint-config": "4.2.0", + "@edx/eslint-config": "4.1.0", "@edx/new-relic-source-map-webpack-plugin": "2.1.0", - "@edx/typescript-config": "1.1.0", + "@edx/typescript-config": "1.0.1", "@formatjs/cli": "^6.0.3", "@fullhuman/postcss-purgecss": "5.0.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", @@ -3235,7 +3152,7 @@ "@types/jest": "29.5.12", "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.58.0", - "autoprefixer": "10.4.20", + "autoprefixer": "10.4.19", "babel-jest": "29.6.1", "babel-loader": "9.1.3", "babel-plugin-formatjs": "^10.4.0", @@ -3263,9 +3180,8 @@ "jest": "29.6.1", "jest-environment-jsdom": "29.6.1", "mini-css-extract-plugin": "1.6.2", - "parse5": "7.1.2", - "postcss": "8.4.47", - "postcss-custom-media": "10.0.8", + "postcss": "8.4.38", + "postcss-custom-media": "10.0.6", "postcss-loader": "7.3.4", "postcss-rtlcss": "5.1.2", "react-dev-utils": "12.0.1", @@ -3283,8 +3199,7 @@ "webpack-bundle-analyzer": "^4.10.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", - "webpack-merge": "^5.10.0", - "webpack-remove-empty-scripts": "1.0.4" + "webpack-merge": "^5.10.0" }, "bin": { "fedx-scripts": "bin/fedx-scripts.js" @@ -3295,8 +3210,6 @@ }, "node_modules/@openedx/frontend-build/node_modules/jest": { "version": "29.6.1", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.6.1.tgz", - "integrity": "sha512-Nirw5B4nn69rVUZtemCQhwxOBhm0nsp3hmtF4rzCeWD7BkjAXRIji7xWQfnTNbz9g0aVsBX6aZK3n+23LM6uDw==", "license": "MIT", "dependencies": { "@jest/core": "^29.6.1", @@ -3639,6 +3552,7 @@ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -5007,19 +4921,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-1.5.2.tgz", - "integrity": "sha512-T3vUABrcgSj/HXv27P+A/JxGk5b/ydx0JjN3lgjBTC2iZUFxQGjh43zCzLSbU4C1QTgmx9oaPeWNJFM+auI8qw==", - "license": "ISC", - "engines": { - "node": ">=12.13" - }, - "funding": { - "type": "patreon", - "url": "https://patreon.com/biodiscus" - } - }, "node_modules/anymatch": { "version": "3.1.3", "license": "ISC", @@ -5198,9 +5099,7 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.19", "funding": [ { "type": "opencollective", @@ -5217,11 +5116,11 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "picocolors": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -5725,9 +5624,7 @@ } }, "node_modules/browserslist": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", - "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "version": "4.23.0", "funding": [ { "type": "opencollective", @@ -5744,10 +5641,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001663", - "electron-to-chromium": "^1.5.28", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" }, "bin": { "browserslist": "cli.js" @@ -5863,9 +5760,7 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001668", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", - "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", + "version": "1.0.30001612", "funding": [ { "type": "opencollective", @@ -7039,6 +6934,12 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, "node_modules/domutils": { "version": "2.8.0", "license": "BSD-2-Clause", @@ -7095,9 +6996,7 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.36.tgz", - "integrity": "sha512-HYTX8tKge/VNp6FGO+f/uVDmUkq+cEfcxYhKf15Akc4M5yxt5YmorwlAitKWjWhWQnKcDRBAQKXkhqqXMqcrjw==", + "version": "1.4.745", "license": "ISC" }, "node_modules/email-prop-type": { @@ -7335,9 +7234,7 @@ } }, "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "version": "3.1.2", "license": "MIT", "engines": { "node": ">=6" @@ -11153,15 +11050,13 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "version": "2.5.2", "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=6" + "node": ">=4" } }, "node_modules/json-buffer": { @@ -11830,9 +11725,7 @@ "peer": true }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.14", "license": "MIT" }, "node_modules/normalize-path": { @@ -12247,9 +12140,7 @@ } }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "version": "1.0.0", "license": "ISC" }, "node_modules/picomatch": { @@ -12454,9 +12345,7 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.4.38", "funding": [ { "type": "opencollective", @@ -12474,8 +12363,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.1.0", - "source-map-js": "^1.2.1" + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -12526,9 +12415,7 @@ } }, "node_modules/postcss-custom-media": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.8.tgz", - "integrity": "sha512-V1KgPcmvlGdxTel4/CyQtBJEFhMVpEmRGFrnVtgfGIHj5PJX9vO36eFBxKBeJn+aCDTed70cc+98Mz3J/uVdGQ==", + "version": "10.0.6", "funding": [ { "type": "github", @@ -12541,10 +12428,10 @@ ], "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.13", - "@csstools/css-parser-algorithms": "^2.7.1", - "@csstools/css-tokenizer": "^2.4.1", - "@csstools/media-query-list-parser": "^2.1.13" + "@csstools/cascade-layer-name-parser": "^1.0.11", + "@csstools/css-parser-algorithms": "^2.6.3", + "@csstools/css-tokenizer": "^2.3.1", + "@csstools/media-query-list-parser": "^2.1.11" }, "engines": { "node": "^14 || ^16 || >=18" @@ -13750,6 +13637,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.20.0" }, @@ -13765,6 +13653,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.20.0", "react-router": "6.27.0" @@ -14741,9 +14630,7 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.0", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -15833,9 +15720,7 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.0.13", "funding": [ { "type": "opencollective", @@ -15852,8 +15737,8 @@ ], "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "escalade": "^3.1.1", + "picocolors": "^1.0.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -16395,25 +16280,6 @@ "node": ">=10.0.0" } }, - "node_modules/webpack-remove-empty-scripts": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/webpack-remove-empty-scripts/-/webpack-remove-empty-scripts-1.0.4.tgz", - "integrity": "sha512-W/Vd94oNXMsQam+W9G+aAzGgFlX1aItcJpkG3byuHGDaxyK3H17oD/b5RcqS/ZHzStIKepksdLDznejDhDUs+Q==", - "license": "ISC", - "dependencies": { - "ansis": "1.5.2" - }, - "engines": { - "node": ">=12.14" - }, - "funding": { - "type": "patreon", - "url": "https://patreon.com/biodiscus" - }, - "peerDependencies": { - "webpack": ">=5.32.0" - } - }, "node_modules/webpack-sources": { "version": "1.4.3", "license": "MIT", diff --git a/package.json b/package.json index b0159597..13977fad 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "react": "17.0.2", "react-dom": "17.0.2", "react-redux": "7.2.9", - "react-router-dom": "^6.25.1", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "redux": "4.2.1", @@ -68,6 +67,7 @@ "babel-polyfill": "6.26.0", "classnames": "2.5.1", "core-js": "3.37.1", + "dompurify": "^3.1.7", "lodash": "4.17.21", "react-redux": "7.2.9", "react-responsive": "^10.0.0", @@ -81,6 +81,7 @@ "@openedx/paragon": "^21.11.0", "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", - "react-dom": "^16.9.0 || ^17.0.0" + "react-dom": "^16.9.0 || ^17.0.0", + "react-router-dom": "^6.0.0" } } diff --git a/src/Notifications/data/__factories__/notifications.factory.js b/src/Notifications/data/__factories__/notifications.factory.js index 043f292f..4e3ad0d6 100644 --- a/src/Notifications/data/__factories__/notifications.factory.js +++ b/src/Notifications/data/__factories__/notifications.factory.js @@ -8,7 +8,8 @@ Factory.define('notificationsCount') grades: 10, authoring: 5, }) - .attr('showNotificationsTray', true); + .attr('showNotificationsTray', true) + .attr('isNewNotificationViewEnabled', false); Factory.define('notification') .sequence('id') diff --git a/src/Notifications/data/selectors.js b/src/Notifications/data/selectors.js index 9f31bb64..c9fcfc1c 100644 --- a/src/Notifications/data/selectors.js +++ b/src/Notifications/data/selectors.js @@ -12,6 +12,8 @@ export const selectSelectedAppNotificationIds = (appName) => state => state.noti export const selectShowNotificationTray = state => state.notifications.showNotificationsTray; +export const selectIsNewNotificationViewEnabled = state => state.notifications.isNewNotificationViewEnabled; + export const selectNotifications = state => state.notifications.notifications; export const selectNotificationsByIds = (appName) => createSelector( diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js index 26923b79..9800a1b7 100644 --- a/src/Notifications/data/slice.js +++ b/src/Notifications/data/slice.js @@ -19,6 +19,7 @@ const initialState = { showNotificationsTray: false, pagination: {}, trayOpened: false, + isNewNotificationViewEnabled: false, }; const slice = createSlice({ name: 'notifications', @@ -54,6 +55,7 @@ const slice = createSlice({ fetchNotificationsCountSuccess: (state, { payload }) => { const { countByAppName, appIds, apps, count, showNotificationsTray, notificationExpiryDays, + isNewNotificationViewEnabled, } = payload; state.tabsCount = { count, ...countByAppName }; state.appsId = appIds; @@ -61,6 +63,7 @@ const slice = createSlice({ state.showNotificationsTray = showNotificationsTray; state.notificationStatus = RequestStatus.SUCCESSFUL; state.notificationExpiryDays = notificationExpiryDays; + state.isNewNotificationViewEnabled = isNewNotificationViewEnabled; }, markAllNotificationsAsReadSuccess: (state) => { const updatedNotifications = Object.fromEntries( diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index 18fdc635..1a63e482 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -16,12 +16,12 @@ import { } from './api'; const normalizeNotificationCounts = ({ - countByAppName, count, showNotificationsTray, notificationExpiryDays, + countByAppName, count, showNotificationsTray, notificationExpiryDays, isNewNotificationViewEnabled, }) => { const appIds = Object.keys(countByAppName); const apps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {}); return { - countByAppName, appIds, apps, count, showNotificationsTray, notificationExpiryDays, + countByAppName, appIds, apps, count, showNotificationsTray, notificationExpiryDays, isNewNotificationViewEnabled, }; }; diff --git a/src/learning-header/AuthenticatedUser.jsx b/src/learning-header/AuthenticatedUser.jsx new file mode 100644 index 00000000..3c48c615 --- /dev/null +++ b/src/learning-header/AuthenticatedUser.jsx @@ -0,0 +1,76 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { AppContext } from '@edx/frontend-platform/react'; +import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; +import NewAuthenticatedUserDropdown from './New-AuthenticatedUserDropdown'; +import messages from './messages'; +import { useNotification } from '../new-notifications/data/hook'; +import Notifications from '../new-notifications'; + +const BaseAuthenticatedUser = ({ children }) => { + const intl = useIntl(); + return ( + <> + + {intl.formatMessage(messages.help)} + + {children} + + ); +}; + +BaseAuthenticatedUser.propTypes = { + children: PropTypes.node.isRequired, +}; + +const AuthenticatedUser = ({ + showUserDropdown, + enterpriseLearnerPortalLink, +}) => { + const { authenticatedUser } = useContext(AppContext); + const { + showTray, + isNewNotificationView, + notificationAppData, + } = useNotification(); + + if (isNewNotificationView) { + return ( + + {showTray && } + {showUserDropdown && ( + + )} + + ); + } + + return ( + + {showUserDropdown && ( + + )} + + ); +}; + +AuthenticatedUser.propTypes = { + enterpriseLearnerPortalLink: PropTypes.string.isRequired, + showUserDropdown: PropTypes.bool.isRequired, +}; + +export default AuthenticatedUser; diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index 03e400ab..4581bc77 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -12,7 +12,7 @@ import { Dropdown, Badge } from '@openedx/paragon'; import messages from './messages'; import Notifications from '../Notifications'; import UserMenuItem from '../common/UserMenuItem'; -import { selectShowNotificationTray } from '../Notifications/data/selectors'; +import { selectShowNotificationTray, selectIsNewNotificationViewEnabled } from '../Notifications/data/selectors'; import { fetchAppsNotificationCount } from '../Notifications/data/thunks'; const AuthenticatedUserDropdown = (props) => { @@ -25,6 +25,7 @@ const AuthenticatedUserDropdown = (props) => { } = props; const dispatch = useDispatch(); const showNotificationsTray = useSelector(selectShowNotificationTray); + const isNewNotificationViewEnabled = useSelector(selectIsNewNotificationViewEnabled); useEffect(() => { dispatch(fetchAppsNotificationCount()); @@ -70,9 +71,12 @@ const AuthenticatedUserDropdown = (props) => { careersMenuItem = ''; } + if (isNewNotificationViewEnabled) { + return null; + } + return ( <> - {intl.formatMessage(messages.help)} {showNotificationsTray && } diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index f6f0f0e7..11022345 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -1,5 +1,6 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; + import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils'; import { APP_CONFIG_INITIALIZED, getConfig, ensureConfig, subscribe, mergeConfig, @@ -8,14 +9,15 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { AppContext, AppProvider } from '@edx/frontend-platform/react'; import classNames from 'classnames'; import AnonymousUserMenu from './AnonymousUserMenu'; -import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; import messages from './messages'; import lightning from '../lightning'; import store from '../store'; +import AuthenticatedUser from './AuthenticatedUser'; ensureConfig([ 'ACCOUNT_SETTINGS_URL', 'NOTIFICATION_FEEDBACK_URL', + 'CAREERS_URL', ], 'Learning Header component'); subscribe(APP_CONFIG_INITIALIZED, () => { @@ -23,6 +25,7 @@ subscribe(APP_CONFIG_INITIALIZED, () => { mergeConfig({ ACCOUNT_SETTINGS_URL: process.env.ACCOUNT_SETTINGS_URL || '', NOTIFICATION_FEEDBACK_URL: process.env.NOTIFICATION_FEEDBACK_URL || '', + CAREERS_URL: process.env.CAREERS_URL || '', }, 'Learning Header additional config'); }); @@ -88,16 +91,14 @@ const LearningHeader = ({ {courseOrg} {courseNumber} {courseTitle} - {showUserDropdown && authenticatedUser && ( - + {authenticatedUser && ( + )} {showUserDropdown && !authenticatedUser && ( - + )} diff --git a/src/learning-header/New-AuthenticatedUserDropdown.jsx b/src/learning-header/New-AuthenticatedUserDropdown.jsx new file mode 100644 index 00000000..152a4a96 --- /dev/null +++ b/src/learning-header/New-AuthenticatedUserDropdown.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserCircle } from '@fortawesome/free-solid-svg-icons'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Dropdown, Badge } from '@openedx/paragon'; + +import messages from './messages'; +import UserMenuItem from '../common/UserMenuItem'; + +const NewAuthenticatedUserDropdown = (props) => { + const { + intl, + enterpriseLearnerPortalLink, + username, + name, + email, + } = props; + + let dashboardMenuItem = ( + + {intl.formatMessage(messages.dashboard)} + + ); + + let careersMenuItem = ( + + {intl.formatMessage(messages.career)} + + {intl.formatMessage(messages.newAlert)} + + + ); + + const userMenuItem = (name || email) ? ( + + + + ) : null; + + if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) { + dashboardMenuItem = ( + + {enterpriseLearnerPortalLink.content} + + ); + careersMenuItem = ''; + } + + return ( + + + + + + {userMenuItem} + {dashboardMenuItem} + {careersMenuItem} + + {intl.formatMessage(messages.profile)} + + + {intl.formatMessage(messages.account)} + + {!enterpriseLearnerPortalLink && getConfig().ORDER_HISTORY_URL && ( + // Users should only see Order History if they do not have an available + // learner portal, because an available learner portal currently means + // that they access content via B2B Subscriptions, in which context an "order" + // is not relevant. + + {intl.formatMessage(messages.orderHistory)} + + )} + + {intl.formatMessage(messages.signOut)} + + + + ); +}; + +NewAuthenticatedUserDropdown.propTypes = { + enterpriseLearnerPortalLink: PropTypes.string, + intl: intlShape.isRequired, + username: PropTypes.string.isRequired, + name: PropTypes.string, + email: PropTypes.string, +}; + +NewAuthenticatedUserDropdown.defaultProps = { + enterpriseLearnerPortalLink: '', + name: '', + email: '', +}; + +export default injectIntl(NewAuthenticatedUserDropdown); diff --git a/src/new-notifications/NotificationEmptySection.jsx b/src/new-notifications/NotificationEmptySection.jsx new file mode 100644 index 00000000..07665af4 --- /dev/null +++ b/src/new-notifications/NotificationEmptySection.jsx @@ -0,0 +1,42 @@ +import React, { useContext } from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton } from '@openedx/paragon'; +import { NotificationsNone } from '@openedx/paragon/icons'; + +import NotificationPopoverContext from './context/notificationPopoverContext'; +import messages from './messages'; + +const EmptyNotifications = () => { + const intl = useIntl(); + const { popoverHeaderRef, notificationRef } = useContext(NotificationPopoverContext); + + return ( +
+ +
+ {intl.formatMessage(messages.noNotificationsYetMessage)} +
+
+ + {intl.formatMessage(messages.noNotificationHelpMessage)} + +
+
+ ); +}; + +export default React.memo(EmptyNotifications); diff --git a/src/new-notifications/NotificationRowItem.jsx b/src/new-notifications/NotificationRowItem.jsx new file mode 100644 index 00000000..0bce0c30 --- /dev/null +++ b/src/new-notifications/NotificationRowItem.jsx @@ -0,0 +1,96 @@ +import React, { useCallback, useContext } from 'react'; +import PropTypes from 'prop-types'; + +import * as timeago from 'timeago.js'; +import DOMPurify from 'dompurify'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, Hyperlink } from '@openedx/paragon'; + +import messages from './messages'; +import timeLocale from '../common/time-locale'; +import { getIconByType } from './utils'; +import { useNotification } from './data/hook'; +import { notificationsContext } from './context/notificationsContext'; + +const NotificationRowItem = ({ + id, type, contentUrl, content, courseName, createdAt, lastRead, +}) => { + timeago.register('time-locale', timeLocale); + const intl = useIntl(); + const { markNotificationsAsRead } = useNotification(); + const { updateNotificationData } = useContext(notificationsContext); + const sanitizedContent = DOMPurify.sanitize(content); + + const handleMarkAsRead = useCallback(async () => { + if (!lastRead) { + const data = await markNotificationsAsRead(id); + updateNotificationData(data); + } + }, [id, lastRead, markNotificationsAsRead, updateNotificationData]); + + const handleNotificationClick = async (event) => { + event.preventDefault(); + + await handleMarkAsRead(); + + window.open(contentUrl, '_blank'); + }; + + const { icon: iconComponent, class: iconClass } = getIconByType(type); + + return ( + handleNotificationClick(event, contentUrl)} + data-testid={`notification-${id}`} + showLaunchIcon={false} + > + +
+
+
+ +
+ + {courseName} + + {intl.formatMessage(messages.fullStop)} + {timeago.format(createdAt, 'time-locale')} + + +
+
+ {!lastRead && ( +
+ +
+ )} +
+
+
+ ); +}; + +NotificationRowItem.propTypes = { + id: PropTypes.number.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); diff --git a/src/new-notifications/NotificationSections.jsx b/src/new-notifications/NotificationSections.jsx new file mode 100644 index 00000000..c462d6b1 --- /dev/null +++ b/src/new-notifications/NotificationSections.jsx @@ -0,0 +1,136 @@ +import React, { useCallback, useContext, useMemo } from 'react'; + +import classNames from 'classnames'; +import isEmpty from 'lodash/isEmpty'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Icon, Spinner } from '@openedx/paragon'; +import { AutoAwesome, CheckCircleLightOutline } from '@openedx/paragon/icons'; + +import { RequestStatus } from './data/constants'; +import NotificationPopoverContext from './context/notificationPopoverContext'; +import messages from './messages'; +import NotificationEmptySection from './NotificationEmptySection'; +import NotificationRowItem from './NotificationRowItem'; +import { splitNotificationsByTime } from './utils'; +import { notificationsContext } from './context/notificationsContext'; +import { useNotification } from './data/hook'; + +const NotificationSections = () => { + const intl = useIntl(); + const { + appName, notificationListStatus, pagination, + notificationExpiryDays, appsId, updateNotificationData, + } = useContext(notificationsContext); + const { getNotifications, markAllNotificationsAsRead, fetchNotificationList } = useNotification(); + const notificationList = getNotifications(); + const { hasMorePages, currentPage } = pagination || {}; + const { popoverHeaderRef, notificationRef } = useContext(NotificationPopoverContext); + const { today = [], earlier = [] } = useMemo( + () => splitNotificationsByTime(notificationList), + [notificationList], + ); + + const handleMarkAllAsRead = useCallback(async () => { + const data = await markAllNotificationsAsRead(appName); + updateNotificationData(data); + }, [appName, markAllNotificationsAsRead, updateNotificationData]); + + const loadMoreNotifications = useCallback(async () => { + const data = await fetchNotificationList(appName, currentPage + 1); + updateNotificationData(data); + }, [fetchNotificationList, appName, currentPage, updateNotificationData]); + + const renderNotificationSection = (section, items) => { + if (isEmpty(items)) { return null; } + + return ( +
+
+ + {section === 'today' && intl.formatMessage(messages.notificationTodayHeading)} + {section === 'earlier' && intl.formatMessage(messages.notificationEarlierHeading)} + + {notificationList?.length > 0 && (section === 'earlier' ? today.length === 0 : true) && ( + + )} +
+ {items.map((notification) => ( + + ))} +
+ ); + }; + + const shouldRenderEmptyNotifications = notificationList?.length === 0 + && notificationListStatus === RequestStatus.SUCCESSFUL + && notificationRef?.current + && popoverHeaderRef?.current; + + return ( +
1, + 'pb-3.5': appsId.length > 0, + })} + data-testid="notification-tray-section" + > + {renderNotificationSection('today', today)} + {renderNotificationSection('earlier', earlier)} + {(hasMorePages === undefined || hasMorePages) && notificationListStatus === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : (hasMorePages && notificationListStatus === RequestStatus.SUCCESSFUL && notificationList.length >= 10 && ( + + ) + )} + { + notificationList.length > 0 && !hasMorePages && notificationListStatus === RequestStatus.SUCCESSFUL && ( +
+ +
+ {intl.formatMessage(messages.allRecentNotificationsMessage)} +
+
+ + + {intl.formatMessage(messages.expiredNotificationsDeleteMessage, { days: notificationExpiryDays })} + +
+
+ ) + } + + {shouldRenderEmptyNotifications && } +
+ ); +}; + +export default React.memo(NotificationSections); diff --git a/src/new-notifications/NotificationTabs.jsx b/src/new-notifications/NotificationTabs.jsx new file mode 100644 index 00000000..1a5a88de --- /dev/null +++ b/src/new-notifications/NotificationTabs.jsx @@ -0,0 +1,67 @@ +import React, { + useEffect, useContext, useCallback, useRef, +} from 'react'; + +import { Tab, Tabs } from '@openedx/paragon'; + +import NotificationSections from './NotificationSections'; +import { useFeedbackWrapper } from './utils'; +import { notificationsContext } from './context/notificationsContext'; +import { useNotification } from './data/hook'; + +const NotificationTabs = () => { + useFeedbackWrapper(); + const { + appName, handleActiveTab, tabsCount, appsId, updateNotificationData, + } = useContext(notificationsContext); + const fetchNotificationsRef = useRef(null); + const { fetchNotificationList, markNotificationsAsSeen } = useNotification(); + + const fetchNotificationsList = useCallback(async () => { + const data = await fetchNotificationList(appName); + updateNotificationData(data); + + if (tabsCount[appName]) { + await markNotificationsAsSeen(appName); + } + }, [appName, fetchNotificationList, updateNotificationData, markNotificationsAsSeen, tabsCount]); + + useEffect(() => { + fetchNotificationsRef.current = fetchNotificationsList; + }, [fetchNotificationsList]); + + useEffect(() => { + const fetchNotifications = async () => { + await fetchNotificationsRef.current(); + }; + fetchNotifications(); + }, [appName]); + + return ( + appsId.length > 1 + ? ( + + {appsId.map((app) => ( + + {appName === app && } + + ))} + + ) + : + ); +}; + +export default React.memo(NotificationTabs); diff --git a/src/new-notifications/context/notificationPopoverContext.js b/src/new-notifications/context/notificationPopoverContext.js new file mode 100644 index 00000000..620c80e9 --- /dev/null +++ b/src/new-notifications/context/notificationPopoverContext.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const notificationPopoverContext = React.createContext({ popoverHeaderRef: null, notificationRef: null }); + +export default notificationPopoverContext; diff --git a/src/new-notifications/context/notificationsContext.js b/src/new-notifications/context/notificationsContext.js new file mode 100644 index 00000000..79c90d42 --- /dev/null +++ b/src/new-notifications/context/notificationsContext.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { RequestStatus } from '../data/constants'; + +export const initialState = { + notificationStatus: RequestStatus.IDLE, + notificationListStatus: RequestStatus.IDLE, + appName: 'discussion', + appsId: [], + apps: {}, + notifications: {}, + tabsCount: {}, + showNotificationsTray: false, + pagination: {}, + trayOpened: false, +}; + +export const notificationsContext = React.createContext(initialState); diff --git a/src/new-notifications/data/__factories__/index.js b/src/new-notifications/data/__factories__/index.js new file mode 100644 index 00000000..cdf7f0b4 --- /dev/null +++ b/src/new-notifications/data/__factories__/index.js @@ -0,0 +1 @@ +import './notifications.factory'; diff --git a/src/new-notifications/data/__factories__/notifications.factory.js b/src/new-notifications/data/__factories__/notifications.factory.js new file mode 100644 index 00000000..6c9e3a31 --- /dev/null +++ b/src/new-notifications/data/__factories__/notifications.factory.js @@ -0,0 +1,32 @@ +import { Factory } from 'rosie'; + +Factory.define('notificationsCount') + .attr('count', 45) + .attr('countByAppName', { + reminders: 10, + discussion: 20, + grades: 10, + authoring: 5, + }) + .attr('showNotificationsTray', true) + .attr('isNewNotificationViewEnabled', true); + +Factory.define('notification') + .sequence('id') + .attr('type', 'post') + .sequence('content', ['id'], (idx, notificationId) => `

User ${idx} posts Hello and welcome to SC0x + ${notificationId}!

`) + .attr('course_name', 'Supply Chain Analytics') + .sequence('content_url', (idx) => `https://example.com/${idx}`) + .attr('last_read', null) + .attr('last_seen', null) + .sequence('created', ['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() })); diff --git a/src/new-notifications/data/api.js b/src/new-notifications/data/api.js new file mode 100644 index 00000000..8d2c7777 --- /dev/null +++ b/src/new-notifications/data/api.js @@ -0,0 +1,37 @@ +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, pageSize, trayOpened) { + const params = snakeCaseObject({ + appName, page, pageSize, trayOpened, + }); + 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 }; +} diff --git a/src/new-notifications/data/api.test.js b/src/new-notifications/data/api.test.js new file mode 100644 index 00000000..a905f6c2 --- /dev/null +++ b/src/new-notifications/data/api.test.js @@ -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); + } + }); +}); diff --git a/src/new-notifications/data/constants.js b/src/new-notifications/data/constants.js new file mode 100644 index 00000000..5b6485b6 --- /dev/null +++ b/src/new-notifications/data/constants.js @@ -0,0 +1,13 @@ +/* eslint-disable import/prefer-default-export */ +/** + * Enum for request status. + * @readonly + * @enum {string} + */ +export const RequestStatus = { + IDLE: 'idle', + IN_PROGRESS: 'in-progress', + SUCCESSFUL: 'successful', + FAILED: 'failed', + DENIED: 'denied', +}; diff --git a/src/new-notifications/data/hook.js b/src/new-notifications/data/hook.js new file mode 100644 index 00000000..9c8b5637 --- /dev/null +++ b/src/new-notifications/data/hook.js @@ -0,0 +1,203 @@ +import { + useContext, useCallback, useEffect, useState, +} from 'react'; +import { useLocation } from 'react-router-dom'; + +import { camelCaseObject } from '@edx/frontend-platform'; +import { AppContext } from '@edx/frontend-platform/react'; + +import { breakpoints, useWindowSize } from '@openedx/paragon'; +import { RequestStatus } from './constants'; +import { notificationsContext } from '../context/notificationsContext'; +import { + getNotificationsList, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead, +} from './api'; + +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; +} + +export function useNotification() { + const { + appName, apps, tabsCount, notifications, updateNotificationData, + } = useContext(notificationsContext); + const { authenticatedUser } = useContext(AppContext); + const [showTray, setShowTray] = useState(); + const [isNewNotificationView, setIsNewNotificationView] = useState(false); + const [notificationAppData, setNotificationAppData] = useState(); + const location = useLocation(); + + const normalizeNotificationCounts = useCallback(({ countByAppName, ...countData }) => { + const appIds = Object.keys(countByAppName); + const notificationApps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {}); + + return { + ...countData, + appIds, + notificationApps, + countByAppName, + }; + }, []); + + 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, + }; + }; + + const getNotifications = useCallback(() => { + try { + const notificationIds = apps[appName] || []; + + return notificationIds.map((notificationId) => notifications[notificationId]) || []; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [apps, appName, notifications]); + + const fetchAppsNotificationCount = useCallback(async () => { + try { + const data = await getNotificationCounts(); + const normalisedData = normalizeNotificationCounts(camelCaseObject(data)); + + const { + countByAppName, appIds, notificationApps, count, showNotificationsTray, notificationExpiryDays, + isNewNotificationViewEnabled, + } = normalisedData; + + return { + tabsCount: { count, ...countByAppName }, + appsId: appIds, + apps: notificationApps, + showNotificationsTray, + notificationStatus: RequestStatus.SUCCESSFUL, + notificationExpiryDays, + isNewNotificationViewEnabled, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [normalizeNotificationCounts]); + + const fetchNotificationList = useCallback(async (app, page = 1, pageSize = 10, trayOpened = true) => { + try { + updateNotificationData({ notificationListStatus: RequestStatus.IN_PROGRESS }); + const data = await getNotificationsList(app, page, pageSize, trayOpened); + const normalizedData = normalizeNotifications((camelCaseObject(data))); + + const { + newNotificationIds, notificationsKeyValuePair, pagination, + } = normalizedData; + + const existingNotificationIds = apps[appName]; + const { count } = tabsCount; + + return { + apps: { + ...apps, + [appName]: Array.from(new Set([...existingNotificationIds, ...newNotificationIds])), + }, + notifications: { ...notifications, ...notificationsKeyValuePair }, + tabsCount: { + ...tabsCount, + count: count - tabsCount[appName], + [appName]: 0, + }, + notificationListStatus: RequestStatus.SUCCESSFUL, + pagination, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [appName, apps, tabsCount, notifications, updateNotificationData]); + + const markNotificationsAsSeen = useCallback(async (app) => { + try { + await markNotificationSeen(app); + + return { notificationStatus: RequestStatus.SUCCESSFUL }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, []); + + const markAllNotificationsAsRead = useCallback(async (app) => { + try { + await markAllNotificationRead(app); + const updatedNotifications = Object.fromEntries( + Object.entries(notifications).map(([key, notification]) => [ + key, { ...notification, lastRead: new Date().toISOString() }, + ]), + ); + + return { + notifications: updatedNotifications, + notificationStatus: RequestStatus.SUCCESSFUL, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [notifications]); + + const markNotificationsAsRead = useCallback(async (notificationId) => { + try { + const data = camelCaseObject(await markNotificationRead(notificationId)); + + const date = new Date().toISOString(); + const notificationList = { ...notifications }; + notificationList[data.id] = { ...notifications[data.id], lastRead: date }; + + return { + notifications: notificationList, + notificationStatus: RequestStatus.SUCCESSFUL, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [notifications]); + + const fetchNotificationData = useCallback(async () => { + const data = await fetchAppsNotificationCount(); + const { showNotificationsTray, isNewNotificationViewEnabled } = data; + + setShowTray(showNotificationsTray); + setIsNewNotificationView(isNewNotificationViewEnabled); + setNotificationAppData(data); + }, [fetchAppsNotificationCount]); + + useEffect(() => { + const fetchNotifications = async () => { + await fetchNotificationData(); + }; + // Only fetch notifications when user is authenticated + if (authenticatedUser) { + fetchNotifications(); + } + }, [fetchNotificationData, authenticatedUser, location.pathname]); + + return { + fetchAppsNotificationCount, + fetchNotificationList, + getNotifications, + markNotificationsAsSeen, + markAllNotificationsAsRead, + markNotificationsAsRead, + showTray, + isNewNotificationView, + notificationAppData, + }; +} diff --git a/src/new-notifications/index.jsx b/src/new-notifications/index.jsx new file mode 100644 index 00000000..af65e2a5 --- /dev/null +++ b/src/new-notifications/index.jsx @@ -0,0 +1,231 @@ +import React, { + useCallback, useEffect, useMemo, useRef, useState, +} from 'react'; + +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Bubble, Button, Hyperlink, Icon, IconButton, OverlayTrigger, Popover, +} from '@openedx/paragon'; +import { NotificationsNone, Settings } from '@openedx/paragon/icons'; +import { RequestStatus } from './data/constants'; + +import { useIsOnLargeScreen, useIsOnMediumScreen } from './data/hook'; +import NotificationTour from './tours/NotificationTour'; +import NotificationPopoverContext from './context/notificationPopoverContext'; +import messages from './messages'; +import NotificationTabs from './NotificationTabs'; +import { notificationsContext } from './context/notificationsContext'; + +import './notification.scss'; + +const Notifications = ({ notificationAppData, showLeftMargin }) => { + const intl = useIntl(); + const popoverRef = useRef(null); + const headerRef = useRef(null); + const buttonRef = useRef(null); + const [enableNotificationTray, setEnableNotificationTray] = useState(false); + const [appName, setAppName] = useState('discussion'); + const [isHeaderVisible, setIsHeaderVisible] = useState(true); + const [notificationData, setNotificationData] = useState({}); + const [tabsCount, setTabsCount] = useState(notificationAppData?.tabsCount); + const isOnMediumScreen = useIsOnMediumScreen(); + const isOnLargeScreen = useIsOnLargeScreen(); + + const toggleNotificationTray = useCallback(() => { + setEnableNotificationTray(prevState => !prevState); + }, []); + + const handleClickOutsideNotificationTray = useCallback((event) => { + if (!popoverRef.current?.contains(event.target) && !buttonRef.current?.contains(event.target)) { + setEnableNotificationTray(false); + } + }, []); + + useEffect(() => { + setTabsCount(notificationAppData.tabsCount); + setNotificationData(prevData => ({ + ...prevData, + ...notificationAppData, + })); + }, [notificationAppData]); + + useEffect(() => { + const handleScroll = () => { + setIsHeaderVisible(window.scrollY < 100); + }; + + window.addEventListener('scroll', handleScroll); + document.addEventListener('mousedown', handleClickOutsideNotificationTray); + + return () => { + document.removeEventListener('mousedown', handleClickOutsideNotificationTray); + window.removeEventListener('scroll', handleScroll); + setAppName('discussion'); + }; + }, [handleClickOutsideNotificationTray]); + + const enableFeedback = useCallback(() => { + window.usabilla_live('click'); + }, []); + + const notificationRefs = useMemo( + () => ({ popoverHeaderRef: headerRef, notificationRef: popoverRef }), + [headerRef, popoverRef], + ); + + const handleActiveTab = useCallback((selectedAppName) => { + setAppName(selectedAppName); + setNotificationData(prevData => ({ + ...prevData, + ...{ notificationListStatus: RequestStatus.IDLE }, + })); + }, []); + + const updateNotificationData = useCallback((data) => { + setNotificationData(prevData => ({ + ...prevData, + ...data, + })); + if (data.tabsCount) { + setTabsCount(data?.tabsCount); + } + }, []); + + const notificationContextValue = useMemo(() => ({ + enableNotificationTray, + appName, + handleActiveTab, + updateNotificationData, + ...notificationData, + }), [enableNotificationTray, appName, handleActiveTab, updateNotificationData, notificationData]); + + return ( + + +
+
+ + {intl.formatMessage(messages.notificationTitle)} + + + + +
+ + + + + + {getConfig().NOTIFICATION_FEEDBACK_URL && ( + + )} +
+ + )} + > +
+ + {tabsCount?.count > 0 && ( + = 10, + 'notification-badge-rounded': tabsCount.count < 10, + })} + onClick={toggleNotificationTray} + > + {tabsCount.count >= 100 ?
99

+

+ : tabsCount.count} +
+ )} +
+
+ +
+ ); +}; + +Notifications.propTypes = { + showLeftMargin: PropTypes.bool, + notificationAppData: PropTypes.shape({ + apps: PropTypes.objectOf( + PropTypes.arrayOf(PropTypes.string), + ).isRequired, + appsId: PropTypes.arrayOf(PropTypes.string).isRequired, + isNewNotificationViewEnabled: PropTypes.bool.isRequired, + notificationExpiryDays: PropTypes.number.isRequired, + notificationStatus: PropTypes.string.isRequired, + showNotificationsTray: PropTypes.bool.isRequired, + tabsCount: PropTypes.shape({ + count: PropTypes.number.isRequired, + }).isRequired, + }), +}; + +Notifications.defaultProps = { + showLeftMargin: true, + notificationAppData: { + apps: { }, + tabsCount: { }, + appsId: [], + isNewNotificationViewEnabled: false, + notificationExpiryDays: 0, + notificationStatus: '', + showNotificationsTray: false, + }, +}; + +export default Notifications; diff --git a/src/new-notifications/index.test.jsx b/src/new-notifications/index.test.jsx new file mode 100644 index 00000000..57fe63d7 --- /dev/null +++ b/src/new-notifications/index.test.jsx @@ -0,0 +1,136 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import MockAdapter from 'axios-mock-adapter'; +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 } from '@edx/frontend-platform/react'; + +import LearningHeader from '../learning-header/LearningHeader'; +import * as notificationApi from './data/api'; + +import './data/__factories__'; + +const notificationCountsApiUrl = notificationApi.getNotificationsCountApiUrl(); + +let axiosMock; +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; + +async function renderComponent() { + render( + + + + + + + , + ); +} + +describe('Notification test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + async function setupMockNotificationCountResponse(count = 45, showNotificationsTray = true) { + axiosMock.onGet(notificationCountsApiUrl) + .reply(200, (Factory.build('notificationsCount', { count, showNotificationsTray }))); + } + + it('Successfully showed bell icon and unseen count on it if unseen count is greater then 0.', async () => { + await setupMockNotificationCountResponse(); + await renderComponent(); + + await waitFor(() => { + 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); + await renderComponent(); + + await waitFor(() => { + 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); + await renderComponent(); + + await waitFor(() => { + 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(); + await renderComponent(); + + await waitFor(async () => { + 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()); + }); + }); + + it.each(['/', '/notification', '/my-post'])( + 'Successfully call getNotificationCounts on URL %s change', + async (url) => { + const getNotificationCountsSpy = jest.spyOn(notificationApi, 'getNotificationCounts').mockReturnValue(() => true); + renderComponent(url); + + expect(getNotificationCountsSpy).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/src/new-notifications/messages.js b/src/new-notifications/messages.js new file mode 100644 index 00000000..18dd733f --- /dev/null +++ b/src/new-notifications/messages.js @@ -0,0 +1,66 @@ +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', + }, + feedback: { + id: 'notification.feedback', + defaultMessage: 'Feedback', + description: 'text for feedback widget', + }, + allRecentNotificationsMessage: { + id: 'notification.recent.all.message', + defaultMessage: 'That’s all of your recent notifications!', + description: 'Message visible when all notifications are loaded', + }, + expiredNotificationsDeleteMessage: { + id: 'notification.expired.delete.message', + defaultMessage: 'Notifications are automatically cleared after {days} days', + description: 'Message showing that expired notifications will be deleted', + }, + noNotificationsYetMessage: { + id: 'notification.no.message', + defaultMessage: 'No notifications yet', + description: 'Message visible when there is no notification in the notification tray', + }, + noNotificationHelpMessage: { + id: 'notification.no.help.message', + defaultMessage: 'When you receive notifications they’ll show up here', + description: 'Message showing that when you receive notifications they’ll show up here', + }, + notificationBellIconAltMessage: { + id: 'notification.bell.icon.alt.message', + defaultMessage: 'Notification bell icon', + description: 'Alt message for notification bell icon', + }, +}); + +export default messages; diff --git a/src/new-notifications/notification.scss b/src/new-notifications/notification.scss new file mode 100644 index 00000000..1658f4b3 --- /dev/null +++ b/src/new-notifications/notification.scss @@ -0,0 +1,228 @@ +@import "~@edx/brand/paragon/fonts.scss"; +@import "~@openedx/paragon/scss/core/core.scss"; + +.zIndex-2 { + z-index: 2 !important; +} + +#pgn__checkpoint { + z-index: 1 !important; +} + +.cursor-pointer { + cursor: pointer; +} + +#notificationIcon { + .plus-icon { + margin-top: -0.5px; + } + + .notification-button { + &:focus, + &:active { + box-shadow: inset 0 0 0 2px $primary-500 !important; + } + + &:focus, + &:active, + &:hover { + background-color: $light-300 !important; + } + + span:first-child { + margin: 0px !important; + } + } + + .notification-lg-bell-icon { + width: 56px !important; + height: 56px !important; + + span:first-child { + width: 32px !important; + height: 32px !important; + } + } + + .notification-button.btn-icon-light-active { + background-color: $light-300 !important; + } + + .notification-badge { + position: absolute; + border: 2px solid #FFFFFF; + font-size: $h6-font-size !important; + font-weight: $font-weight-semi-bold !important; + font-variant-numeric: lining-nums tabular-nums; + background: var(--text-on-light-brand-500, $brand-500) !important; + line-height: 20px !important; + margin-left: -21px; + } + + .notification-badge-unrounded { + min-width: 23px !important; + height: 16px; + min-height: 16px !important; + border-radius: 54px !important; + } + + .notification-badge-rounded { + border-radius: 50%; + margin-top: 1px; + height: 20px; + min-height: 20px !important; + width: 20px !important; + min-width: 20px !important; + } +} + +#notificationTray { + filter: none; + box-shadow: $box-shadow-down-2; + + .popover-header { + top: 0px; + } + + .tabs { + top: 0px; + } + + &.medium-screen { + min-width: 34.313rem; + } + + &.large-screen { + min-width: 34.313rem; + } + + &.popover-margin-top { + margin-top: -60px !important; + } + + &.height-100vh { + height: 100vh; + } + + &.height-91vh { + height: 91vh; + } + + .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: $h5-font-size; + padding-top: 0px !important; + padding-bottom: 12px !important; + + div { + min-height: 6px !important; + min-width: 6px !important; + } + } + + .dropdown-item { + font-size: $h5-font-size; + font-weight: $font-weight-semi-bold; + } + + .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: $primary-500; + } + } + + .unread { + height: 10px; + width: 10px; + } + + .nav-tabs .nav-link { + &:focus { + &::before { + border: none !important; + } + } + } + } + + .notification-feedback-widget { + right: -37px !important; + position: fixed !important; + transform: rotate(270deg) !important; + top: 50% !important; + } + + .height-inherit { + height: inherit; + } + + .line-height-normal { + line-height: normal; + } + + .notification-end-title { + font-weight: $font-weight-semi-bold !important; + color: $primary-500 !important; + } + + .notification-icon { + height: $icon-md !important; + width: $icon-md !important; + z-index: 1; + } + + .icon-size-56 { + width: 56px !important; + height: 56px !important; + } + + .icon-size-20 { + width: $icon-sm; + height: $icon-sm; + } + + .line-height-24 { + line-height: 24px; + } + + .line-height-20 { + line-height: 20px; + } + + .line-height-10 { + line-height: 10px !important; + } + + .font-size-18 { + font-size: $h4-font-size !important; + } + + .content { + strong { + color: $primary-500 !important; + font-weight: $font-weight-semi-bold !important; + } + } +} diff --git a/src/new-notifications/notificationRowItem.test.jsx b/src/new-notifications/notificationRowItem.test.jsx new file mode 100644 index 00000000..4418f54e --- /dev/null +++ b/src/new-notifications/notificationRowItem.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, + waitFor, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { Factory } from 'rosie'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppContext } from '@edx/frontend-platform/react'; + +import LearningHeader from '../learning-header/LearningHeader'; +import mockNotificationsResponse from './test-utils'; + +import './data/__factories__'; + +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; + +async function renderComponent() { + render( + + + + + + + , + ); +} + +describe('Notification row item test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + Factory.resetAll(); + + await mockNotificationsResponse(); + }); + + it( + 'Successfully viewed notification icon, notification context, unread , course name and notification time.', + async () => { + await renderComponent(); + + await waitFor(async () => { + 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 () => { + await renderComponent(); + + await waitFor(async () => { + 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(); + }); + }); +}); diff --git a/src/new-notifications/notificationSections.test.jsx b/src/new-notifications/notificationSections.test.jsx new file mode 100644 index 00000000..ee011ea3 --- /dev/null +++ b/src/new-notifications/notificationSections.test.jsx @@ -0,0 +1,141 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, waitFor, within, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import MockAdapter from 'axios-mock-adapter'; +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 } from '@edx/frontend-platform/react'; + +import LearningHeader from '../learning-header/LearningHeader'; +import { getNotificationsListApiUrl, getNotificationsCountApiUrl } from './data/api'; +import mockNotificationsResponse from './test-utils'; +import './data/__factories__'; +import { getDiscussionTourUrl } from './tours/data/api'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsListApiUrl(); +const notificationsTourApiUrl = getDiscussionTourUrl(); + +let axiosMock; +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; + +async function renderComponent() { + render( + + + + + + + , + ); +} + +describe('Notification sections test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + }); + + it('Successfully viewed last 24 hours and earlier section along with mark all as read label.', async () => { + await mockNotificationsResponse(); + await renderComponent(); + + await waitFor(async () => { + 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 () => { + await mockNotificationsResponse(); + await renderComponent(); + + await waitFor(async () => { + 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); }); + }); + + await waitFor(async () => { + expect(screen.queryByTestId('unread-notification-1')).not.toBeInTheDocument(); + }); + }); + + it('Successfully load more notifications by clicking on load more notification button.', async () => { + await mockNotificationsResponse(10, 2); + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + + const loadMoreButton = screen.queryByTestId('load-more-notifications'); + await act(async () => { fireEvent.click(loadMoreButton); }); + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('notification-contents')).toHaveLength(12); + }); + }); + + it('Successfully showed No notification yet message when the notification tray is empty.', async () => { + const notificationCountsMock = { + show_notifications_tray: true, + count: 0, + count_by_app_name: { + discussion: 0, + }, + isNewNotificationViewEnabled: true, + }; + + axiosMock.onGet(notificationCountsApiUrl).reply(200, notificationCountsMock); + axiosMock.onGet(notificationsApiUrl).reply(200, { results: [] }); + axiosMock.onGet(notificationsTourApiUrl).reply(200, []); + + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + }); + + await waitFor(() => { + expect(screen.queryByTestId('notifications-empty-list')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/new-notifications/notificationTabs.test.jsx b/src/new-notifications/notificationTabs.test.jsx new file mode 100644 index 00000000..02c79aad --- /dev/null +++ b/src/new-notifications/notificationTabs.test.jsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, waitFor, within, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { Factory } from 'rosie'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppContext } from '@edx/frontend-platform/react'; + +import LearningHeader from '../learning-header/LearningHeader'; +import mockNotificationsResponse from './test-utils'; + +import './data/__factories__'; + +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; + +async function renderComponent() { + render( + + + + + + + , + ); +} + +describe('Notification Tabs test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: '123abc', + username: 'testuser', + administrator: false, + roles: [], + }, + }); + + Factory.resetAll(); + + await mockNotificationsResponse(); + }); + + it('Successfully displayed with default discussion tab selected under notification tabs .', async () => { + await renderComponent(); + + await waitFor(async () => { + 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(); + }); + }); + + it('Successfully showed unseen counts for unselected tabs.', async () => { + await renderComponent(); + await waitFor(async () => { + 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 () => { + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + const notificationTab = screen.getAllByRole('tab'); + let selectedTab = screen.queryByTestId('notification-tab-reminders'); + + expect(selectedTab).not.toHaveClass('active'); + + fireEvent.click(notificationTab[0], { dataset: { rbEventKey: 'reminders' } }); + selectedTab = screen.queryByTestId('notification-tab-reminders'); + + expect(selectedTab).toHaveClass('active'); + }); + }); +}); diff --git a/src/new-notifications/test-utils.js b/src/new-notifications/test-utils.js new file mode 100644 index 00000000..655c7440 --- /dev/null +++ b/src/new-notifications/test-utils.js @@ -0,0 +1,32 @@ +import MockAdapter from 'axios-mock-adapter'; +import { Factory } from 'rosie'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { + getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationsSeenApiUrl, markNotificationAsReadApiUrl, +} from './data/api'; + +import './data/__factories__'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsListApiUrl(); +const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion'); +const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl(); + +export default async function mockNotificationsResponse(todaycount = 8, earlierCount = 2) { + const axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + const notifications = (Factory.buildList('notification', todaycount, null, { createdDate: new Date().toISOString() }).concat( + Factory.buildList('notification', earlierCount, null, { createdDate: '2023-06-01T00:46:11.979531Z' }), + )); + axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount'))); + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200, { message: 'Notifications marked seen.' }); + axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' }); + + axiosMock.onGet(notificationsApiUrl).reply(200, (Factory.build('notificationsList', { + results: notifications, + num_pages: 2, + current_page: 2, + next: `${notificationsApiUrl}?app_name=discussion&page=2`, + }))); +} diff --git a/src/new-notifications/tours/NotificationTour.jsx b/src/new-notifications/tours/NotificationTour.jsx new file mode 100644 index 00000000..aa141497 --- /dev/null +++ b/src/new-notifications/tours/NotificationTour.jsx @@ -0,0 +1,30 @@ +import React, { useEffect, useContext } from 'react'; +import isEmpty from 'lodash/isEmpty'; +import { ProductTour } from '@openedx/paragon'; +import { useNotificationTour } from './data/hooks'; +import { notificationsContext } from '../context/notificationsContext'; + +const NotificationTour = () => { + const { useTourConfiguration, fetchNotificationTours } = useNotificationTour(); + const config = useTourConfiguration(); + const { updateNotificationData } = useContext(notificationsContext); + + useEffect(() => { + const fetchTourData = async () => { + const data = await fetchNotificationTours(); + updateNotificationData(data); + }; + + fetchTourData(); + }, [fetchNotificationTours, updateNotificationData]); + + return ( + !isEmpty(config) && ( + + ) + ); +}; + +export default NotificationTour; diff --git a/src/new-notifications/tours/constants.js b/src/new-notifications/tours/constants.js new file mode 100644 index 00000000..8d7949dd --- /dev/null +++ b/src/new-notifications/tours/constants.js @@ -0,0 +1,19 @@ +import messages from './messages'; + +/** + * + * @param {Object} intl + * @returns {Object} tour checkpoints + */ +export default function tourCheckpoints(intl) { + return { + EXAMPLE_TOUR: [ + { + title: intl.formatMessage(messages.exampleTourTitle), + body: intl.formatMessage(messages.exampleTourBody), + target: '#example-tour-target', + placement: 'bottom', + }, + ], + }; +} diff --git a/src/new-notifications/tours/data/api.js b/src/new-notifications/tours/data/api.js new file mode 100644 index 00000000..aa8b327c --- /dev/null +++ b/src/new-notifications/tours/data/api.js @@ -0,0 +1,15 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +// create constant for the API URL +export const getDiscussionTourUrl = () => `${getConfig().LMS_BASE_URL}/api/user_tours/discussion_tours/`; + +export async function getNotificationsTours() { + const { data } = await getAuthenticatedHttpClient().get(getDiscussionTourUrl()); + return data; +} + +export async function updateNotificationsTour(tourId) { + const { data } = await getAuthenticatedHttpClient().put(`${getDiscussionTourUrl()}${tourId}`, { show_tour: false }); + return data; +} diff --git a/src/new-notifications/tours/data/hooks.js b/src/new-notifications/tours/data/hooks.js new file mode 100644 index 00000000..b8113dfe --- /dev/null +++ b/src/new-notifications/tours/data/hooks.js @@ -0,0 +1,74 @@ +import { useMemo, useContext, useCallback } from 'react'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from '../messages'; +import tourCheckpoints from '../constants'; +import { getNotificationsTours, updateNotificationsTour } from './api'; +import { RequestStatus } from '../../data/constants'; +import { notificationsContext } from '../../context/notificationsContext'; + +export function camelToConstant(string) { + return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase(); +} + +export function useNotificationTour() { + const { tours, updateNotificationData } = useContext(notificationsContext); + + function normaliseTourData(data) { + return data.map(tour => ({ ...tour, enabled: true })); + } + + const fetchNotificationTours = useCallback(async () => { + try { + const data = await getNotificationsTours(); + const normalizedData = camelCaseObject(normaliseTourData(data)); + + return { tours: normalizedData, loading: RequestStatus.SUCCESSFUL }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, []); + + const updateTourShowStatus = useCallback(async (tourId) => { + try { + const data = await updateNotificationsTour(tourId); + const normalizedData = camelCaseObject(data); + const tourIndex = tours.findIndex(tour => tour.id === normalizedData.id); + tours[tourIndex] = normalizedData; + + return { tours, loading: RequestStatus.SUCCESSFUL }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [tours]); + + const handleOnOkay = useCallback(async (id) => { + const data = await updateTourShowStatus(id); + updateNotificationData(data); + }, [updateNotificationData, updateTourShowStatus]); + + const useTourConfiguration = async () => { + const intl = useIntl(); + + const toursConfig = useMemo(() => ( + tours?.map((tour) => Object.keys(tourCheckpoints(intl)).includes(tour.tourName) && ( + { + tourId: tour.tourName, + dismissButtonText: intl.formatMessage(messages.dismissButtonText), + endButtonText: intl.formatMessage(messages.endButtonText), + enabled: tour && Boolean(tour.enabled && tour.showTour), + onEnd: () => handleOnOkay(tour.id), + checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)], + } + )) + ), [intl]); + + return toursConfig; + }; + + return { + fetchNotificationTours, + updateTourShowStatus, + useTourConfiguration, + }; +} diff --git a/src/new-notifications/tours/messages.js b/src/new-notifications/tours/messages.js new file mode 100644 index 00000000..84a98352 --- /dev/null +++ b/src/new-notifications/tours/messages.js @@ -0,0 +1,26 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + dismissButtonText: { + id: 'tour.action.dismiss', + defaultMessage: 'Dismiss', + description: 'Action to dismiss current tour', + }, + endButtonText: { + id: 'tour.action.end', + defaultMessage: 'Okay', + description: 'Action to end current tour', + }, + exampleTourTitle: { + id: 'tour.example.title', + defaultMessage: 'Example Tour', + description: 'Title for example tour', + }, + exampleTourBody: { + id: 'tour.example.body', + defaultMessage: 'This is an example tour', + description: 'Body for example tour', + }, +}); + +export default messages; diff --git a/src/new-notifications/utils.js b/src/new-notifications/utils.js new file mode 100644 index 00000000..ef68d9ab --- /dev/null +++ b/src/new-notifications/utils.js @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; + +import { getConfig } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; +import { + QuestionAnswerOutline, + PostOutline, + Report, + Verified, + Newspaper, +} from '@openedx/paragon/icons'; + +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.created).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 = { + new_response: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + new_comment: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + new_comment_on_response: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + content_reported: { icon: Report, class: 'text-danger' }, + response_endorsed: { icon: Verified, class: 'text-primary-500' }, + response_endorsed_on_thread: { icon: Verified, class: 'text-primary-500' }, + course_update: { icon: Newspaper, class: 'text-primary-500' }, + }; + return iconMap[type] || { icon: PostOutline, class: 'text-primary-500' }; +}; + +export function useFeedbackWrapper() { + useEffect(() => { + try { + const url = getConfig().NOTIFICATION_FEEDBACK_URL; + if (url) { + window.usabilla_live = lightningjs.require('usabilla_live', getConfig().NOTIFICATION_FEEDBACK_URL); + window.usabilla_live('hide'); + } + } catch (error) { + logError('Error loading usabilla_live in notificationTray', error); + } + }, []); +} diff --git a/src/setupTest.js b/src/setupTest.js index d231cd58..813df825 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -49,6 +49,7 @@ class MockLoggingService { export const authenticatedUser = { userId: 'abc123', username: 'Mock User', + name: 'edX', roles: [], administrator: false, }; @@ -70,6 +71,7 @@ export function initializeMockApp() { authenticatedUser: { userId: 'abc123', username: 'Mock User', + name: 'edX', roles: [], administrator: false, },