diff --git a/example/index.js b/example/index.js
index e9b44a5..deba37a 100644
--- a/example/index.js
+++ b/example/index.js
@@ -4,7 +4,8 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { initialize, getConfig, subscribe, APP_READY } from '@edx/frontend-platform';
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
-import Header from '@edx/frontend-component-header';
+// import Header from '@edx/frontend-component-header';
+import { LearningHeader as Header } from '@edx/frontend-component-header';
import './index.scss';
diff --git a/package-lock.json b/package-lock.json
index 57e9165..a85b517 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,9 +15,15 @@
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
+ "@reduxjs/toolkit": "1.9.5",
"babel-polyfill": "6.26.0",
+ "classnames": "2.3.2",
+ "lodash": "4.17.21",
+ "react-redux": "7.2.9",
"react-responsive": "8.2.0",
- "react-transition-group": "4.4.5"
+ "react-router-dom": "5.3.4",
+ "react-transition-group": "4.4.5",
+ "timeago.js": "4.0.2"
},
"devDependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
@@ -37,7 +43,6 @@
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.9",
- "react-router-dom": "5.3.4",
"react-test-renderer": "16.14.0",
"redux": "4.2.1",
"redux-saga": "1.2.3"
@@ -5807,6 +5812,29 @@
"integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==",
"dev": true
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz",
+ "integrity": "sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==",
+ "dependencies": {
+ "immer": "^9.0.21",
+ "redux": "^4.2.1",
+ "redux-thunk": "^2.4.2",
+ "reselect": "^4.1.8"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18",
+ "react-redux": "^7.2.1 || ^8.0.2"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@restart/context": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz",
@@ -6785,7 +6813,7 @@
"version": "7.1.25",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.25.tgz",
"integrity": "sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
@@ -12841,7 +12869,6 @@
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
- "dev": true,
"dependencies": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
@@ -13296,7 +13323,6 @@
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
- "dev": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -18918,8 +18944,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
@@ -20206,7 +20231,6 @@
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
- "dev": true,
"dependencies": {
"isarray": "0.0.1"
}
@@ -20214,8 +20238,7 @@
"node_modules/path-to-regexp/node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
- "dev": true
+ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
},
"node_modules/path-type": {
"version": "4.0.0",
@@ -21825,7 +21848,7 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true
+ "devOptional": true
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
@@ -21882,7 +21905,7 @@
"version": "7.2.9",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz",
"integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
@@ -21978,7 +22001,6 @@
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
- "dev": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -21998,7 +22020,6 @@
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
"integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
- "dev": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -22015,8 +22036,7 @@
"node_modules/react-router/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-style-singleton": {
"version": "2.2.1",
@@ -22270,7 +22290,6 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
- "dev": true,
"dependencies": {
"@babel/runtime": "^7.9.2"
}
@@ -22284,6 +22303,14 @@
"@redux-saga/core": "^1.2.3"
}
},
+ "node_modules/redux-thunk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz",
+ "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==",
+ "peerDependencies": {
+ "redux": "^4"
+ }
+ },
"node_modules/reflect.ownkeys": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz",
@@ -22472,6 +22499,11 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
},
+ "node_modules/reselect": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
+ "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
+ },
"node_modules/resolve": {
"version": "1.22.2",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz",
@@ -22513,8 +22545,7 @@
"node_modules/resolve-pathname": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
- "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==",
- "dev": true
+ "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
},
"node_modules/resolve-url": {
"version": "0.2.1",
@@ -24714,17 +24745,20 @@
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"dev": true
},
+ "node_modules/timeago.js": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/timeago.js/-/timeago.js-4.0.2.tgz",
+ "integrity": "sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w=="
+ },
"node_modules/tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
- "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
- "dev": true
+ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
- "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
- "dev": true
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"node_modules/tmpl": {
"version": "1.0.5",
@@ -25366,8 +25400,7 @@
"node_modules/value-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
- "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==",
- "dev": true
+ "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
},
"node_modules/vary": {
"version": "1.1.2",
diff --git a/package.json b/package.json
index 8c49398..cf5ec01 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,6 @@
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.9",
- "react-router-dom": "5.3.4",
"react-test-renderer": "16.14.0",
"redux": "4.2.1",
"redux-saga": "1.2.3"
@@ -62,9 +61,15 @@
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
+ "@reduxjs/toolkit": "1.9.5",
"babel-polyfill": "6.26.0",
+ "classnames": "2.3.2",
+ "lodash": "4.17.21",
+ "react-redux": "7.2.9",
"react-responsive": "8.2.0",
- "react-transition-group": "4.4.5"
+ "react-transition-group": "4.4.5",
+ "timeago.js": "4.0.2",
+ "react-router-dom": "5.3.4"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0",
diff --git a/src/Header.test.jsx b/src/Header.test.jsx
index 51fef20..7636460 100644
--- a/src/Header.test.jsx
+++ b/src/Header.test.jsx
@@ -2,24 +2,38 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TestRenderer from 'react-test-renderer';
-import { AppContext } from '@edx/frontend-platform/react';
+import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import { Context as ResponsiveContext } from 'react-responsive';
+import { initializeMockApp } from '@edx/frontend-platform';
+import store from './store';
import Header from './index';
const HeaderComponent = ({ width, contextValue }) => (
SCM_Lead posts Hello and welcome to SC0x!
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:46:11.979531Z" + }, + { + "id": 2, + "type": "help", + "content": "MITx_Learner asked What grade does a student need to get in order to pass the course and earn a certificate?
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 3, + "type": "post", + "content": "SCM_Lead posts Hello and welcome to SC0x!
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:46:11.979531Z" + }, + { + "id": 4, + "type": "respond", + "content": "MITx_Learner responded Can't find linear regression in section 3 review
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 5, + "type": "comment", + "content": "MITx_Learner commented on MITx_Expert's response on a post your following Can't find linear regression in section 3 review
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 6, + "type": "question", + "content": "MITx_Learner commented Examples of quadratic equations in supply chains
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 7, + "type": "answer", + "content": "MITx_Expert answered Examples of quadratic equations in supply chains
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-05T00:36:11.979531Z" + }, + { + "id": 8, + "type": "comment", + "content": "MITx_Learner commented Examples of quadratic equations in supply chains
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 9, + "type": "comment", + "content": "MITx_Learner commented on MITx_Expert'swhat grade does a student need to get in order to pass the course and earn a certificate?
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 10, + "type": "comment", + "content": "MITx_Learner commented on your response in Convexity of f(x)=1/x , x>1
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 11, + "type": "answer", + "content": "SCM_Lead’s response has been marked as answer in your post Quiz in section 3 - Please explain the F-Significance value
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 12, + "type": "endorsed", + "content": "Your response has been endorsed in Quiz in section 3 - Please explain the F-Significance value
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 13, + "type": "reported", + "content": "MITx Learner’s post has been reported “Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”
", + "course_name": "Supply Chain Analytics", + "content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + } + ] +} diff --git a/src/Notifications/data/selectors.js b/src/Notifications/data/selectors.js new file mode 100644 index 0000000..b8d72c2 --- /dev/null +++ b/src/Notifications/data/selectors.js @@ -0,0 +1,23 @@ +import { createSelector } from '@reduxjs/toolkit'; + +export const selectNotificationStatus = () => state => state.notifications.notificationStatus; + +export const selectNotificationTabsCount = () => state => state.notifications.tabsCount; + +export const selectNotificationTabs = () => state => state.notifications.appsId; + +export const selectSelectedAppNotificationIds = (appName) => state => state.notifications.apps[appName] ?? []; + +export const selectShowNotificationTray = () => state => state.notifications.showNotificationTray; + +export const selectNotifications = () => state => state.notifications.notifications; + +export const selectNotificationsByIds = (appName) => createSelector( + selectNotifications(), + selectSelectedAppNotificationIds(appName), + (notifications, notificationIds) => notificationIds.map((notificationId) => notifications[notificationId]) || [], +); + +export const selectSelectedAppName = () => state => state.notifications.appName; + +export const selectPaginationData = () => state => state.notifications.pagination; diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js new file mode 100644 index 0000000..8751475 --- /dev/null +++ b/src/Notifications/data/slice.js @@ -0,0 +1,154 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +export const RequestStatus = { + IDLE: 'idle', + LOADING: 'in-progress', + LOADED: 'successful', + FAILED: 'failed', + DENIED: 'denied', +}; + +const initialState = { + notificationStatus: 'idle', + appName: 'discussions', + appsId: [], + apps: {}, + notifications: {}, + tabsCount: {}, + showNotificationTray: false, + pagination: { + count: 10, + numPages: 1, + currentPage: 1, + nextPage: null, + }, +}; +const slice = createSlice({ + name: 'notifications', + initialState, + reducers: { + fetchNotificationDenied: (state) => { + state.notificationStatus = RequestStatus.DENIED; + }, + fetchNotificationFailure: (state) => { + state.notificationStatus = RequestStatus.FAILED; + }, + fetchNotificationRequest: (state) => { + state.notificationStatus = RequestStatus.LOADING; + }, + fetchNotificationSuccess: (state, { payload }) => { + const { + newNotificationIds, notificationsKeyValuePair, numPages, currentPage, + } = payload; + const existingNotificationIds = state.apps[state.appName]; + + state.apps[state.appName] = Array.from(new Set([...existingNotificationIds, ...newNotificationIds])); + state.notifications = { ...state.notifications, ...notificationsKeyValuePair }; + state.tabsCount.count -= state.tabsCount[state.appName]; + state.tabsCount[state.appName] = 0; + state.notificationStatus = RequestStatus.LOADED; + state.pagination.numPages = numPages; + state.pagination.currentPage = currentPage; + }, + fetchNotificationsCountDenied: (state) => { + state.notificationStatus = RequestStatus.DENIED; + }, + fetchNotificationsCountFailure: (state) => { + state.notificationStatus = RequestStatus.FAILED; + }, + fetchNotificationsCountRequest: (state) => { + state.notificationStatus = RequestStatus.LOADING; + }, + fetchNotificationsCountSuccess: (state, { payload }) => { + const { + countByAppName, appIds, apps, count, showNotificationTray, + } = payload; + state.tabsCount = { count, ...countByAppName }; + state.appsId = appIds; + state.apps = apps; + state.showNotificationTray = showNotificationTray; + state.notificationStatus = RequestStatus.LOADED; + }, + markNotificationsAsSeenRequest: (state) => { + state.notificationStatus = RequestStatus.LOADING; + }, + markNotificationsAsSeenSuccess: (state) => { + state.notificationStatus = RequestStatus.LOADED; + }, + markNotificationsAsSeenDenied: (state) => { + state.notificationStatus = RequestStatus.DENIED; + }, + markNotificationsAsSeenFailure: (state) => { + state.notificationStatus = RequestStatus.FAILED; + }, + markAllNotificationsAsReadRequest: (state) => { + state.notificationStatus = RequestStatus.LOADING; + }, + markAllNotificationsAsReadSuccess: (state) => { + const updatedNotifications = Object.fromEntries( + Object.entries(state.notifications).map(([key, notification]) => [ + key, { ...notification, lastRead: new Date().toISOString() }, + ]), + ); + state.notifications = updatedNotifications; + state.notificationStatus = RequestStatus.LOADED; + }, + markAllNotificationsAsReadDenied: (state) => { + state.notificationStatus = RequestStatus.DENIED; + }, + markAllNotificationsAsReadFailure: (state) => { + state.notificationStatus = RequestStatus.FAILED; + }, + markNotificationsAsReadRequest: (state) => { + state.notificationStatus = RequestStatus.LOADING; + }, + markNotificationsAsReadSuccess: (state, { payload }) => { + const date = new Date().toISOString(); + state.notifications[payload.id] = { ...state.notifications[payload.id], lastRead: date }; + state.notificationStatus = RequestStatus.LOADED; + }, + markNotificationsAsReadDenied: (state) => { + state.notificationStatus = RequestStatus.DENIED; + }, + markNotificationsAsReadFailure: (state) => { + state.notificationStatus = RequestStatus.FAILED; + }, + resetNotificationStateRequest: () => initialState, + updateAppNameRequest: (state, { payload }) => { + state.appName = payload.appName; + state.pagination.currentPage = 1; + }, + updatePaginationRequest: (state) => { + state.pagination.currentPage += 1; + }, + }, +}); + +export const { + fetchNotificationDenied, + fetchNotificationFailure, + fetchNotificationRequest, + fetchNotificationSuccess, + fetchNotificationsCountDenied, + fetchNotificationsCountFailure, + fetchNotificationsCountRequest, + fetchNotificationsCountSuccess, + markNotificationsAsSeenRequest, + markNotificationsAsSeenSuccess, + markNotificationsAsSeenFailure, + markNotificationsAsSeenDenied, + markAllNotificationsAsReadDenied, + markAllNotificationsAsReadRequest, + markAllNotificationsAsReadSuccess, + markAllNotificationsAsReadFailure, + markNotificationsAsReadDenied, + markNotificationsAsReadRequest, + markNotificationsAsReadSuccess, + markNotificationsAsReadFailure, + resetNotificationStateRequest, + updateAppNameRequest, + updatePaginationRequest, +} = slice.actions; + +export const notificationsReducer = slice.reducer; diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js new file mode 100644 index 0000000..1e702b2 --- /dev/null +++ b/src/Notifications/data/thunks.js @@ -0,0 +1,134 @@ +import { + fetchNotificationSuccess, + fetchNotificationRequest, + fetchNotificationFailure, + fetchNotificationDenied, + fetchNotificationsCountFailure, + fetchNotificationsCountRequest, + fetchNotificationsCountSuccess, + fetchNotificationsCountDenied, + markNotificationsAsSeenRequest, + markNotificationsAsSeenSuccess, + markNotificationsAsSeenFailure, + markNotificationsAsSeenDenied, + markNotificationsAsReadDenied, + resetNotificationStateRequest, + markAllNotificationsAsReadRequest, + markAllNotificationsAsReadSuccess, + markAllNotificationsAsReadFailure, + markAllNotificationsAsReadDenied, + markNotificationsAsReadRequest, + markNotificationsAsReadSuccess, + markNotificationsAsReadFailure, +} from './slice'; +import { + getNotifications, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead, +} from './api'; +import { getHttpErrorStatus } from '../utils'; + +const normalizeNotificationCounts = ({ countByAppName, count, showNotificationTray }) => { + const appIds = Object.keys(countByAppName); + const apps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {}); + return { + countByAppName, appIds, apps, count, showNotificationTray, + }; +}; + +const normalizeNotifications = ({ notifications }) => { + const newNotificationIds = notifications.map(notification => notification.id.toString()); + const notificationsKeyValuePair = notifications.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {}); + return { + newNotificationIds, notificationsKeyValuePair, + }; +}; + +export const fetchNotificationList = ({ appName, page, pageSize }) => ( + async (dispatch) => { + try { + dispatch(fetchNotificationRequest({ appName })); + const data = await getNotifications(appName, page, pageSize); + const normalisedData = normalizeNotifications((data)); + dispatch(fetchNotificationSuccess({ ...normalisedData, numPages: data.numPages, currentPage: data.currentPage })); + } catch (error) { + if (getHttpErrorStatus(error) === 403) { + dispatch(fetchNotificationDenied(appName)); + } else { + dispatch(fetchNotificationFailure(appName)); + } + } + } +); + +export const fetchAppsNotificationCount = () => ( + async (dispatch) => { + try { + dispatch(fetchNotificationsCountRequest()); + const data = await getNotificationCounts(); + const normalisedData = normalizeNotificationCounts((data)); + dispatch(fetchNotificationsCountSuccess({ + ...normalisedData, + countByAppName: data.countByAppName, + count: data.count, + showNotificationTray: data.showNotificationTray, + })); + } catch (error) { + if (getHttpErrorStatus(error) === 403) { + dispatch(fetchNotificationsCountDenied()); + } else { + dispatch(fetchNotificationsCountFailure()); + } + } + } +); + +export const markAllNotificationsAsRead = (appName) => ( + async (dispatch) => { + try { + dispatch(markAllNotificationsAsReadRequest({ appName })); + const data = await markAllNotificationRead(appName); + dispatch(markAllNotificationsAsReadSuccess(data)); + } catch (error) { + if (getHttpErrorStatus(error) === 403) { + dispatch(markAllNotificationsAsReadDenied()); + } else { + dispatch(markAllNotificationsAsReadFailure()); + } + } + } +); + +export const markNotificationsAsRead = (notificationId) => ( + async (dispatch) => { + try { + dispatch(markNotificationsAsReadRequest({ notificationId })); + const data = await markNotificationRead(notificationId); + dispatch(markNotificationsAsReadSuccess(data)); + } catch (error) { + if (getHttpErrorStatus(error) === 403) { + dispatch(markNotificationsAsReadDenied()); + } else { + dispatch(markNotificationsAsReadFailure()); + } + } + } +); + +export const markNotificationsAsSeen = (appName) => ( + async (dispatch) => { + try { + dispatch(markNotificationsAsSeenRequest({ appName })); + const data = await markNotificationSeen(appName); + dispatch(markNotificationsAsSeenSuccess(data)); + } catch (error) { + if (getHttpErrorStatus(error) === 403) { + dispatch(markNotificationsAsSeenDenied()); + } else { + dispatch(markNotificationsAsSeenFailure()); + } + } + } +); + +export const resetNotificationState = () => ( + async (dispatch) => { dispatch(resetNotificationStateRequest()); } +); diff --git a/src/Notifications/index.jsx b/src/Notifications/index.jsx new file mode 100644 index 0000000..30d28b9 --- /dev/null +++ b/src/Notifications/index.jsx @@ -0,0 +1,101 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; +import { + Badge, Icon, IconButton, OverlayTrigger, Popover, +} from '@edx/paragon'; +import { NotificationsNone, Settings } from '@edx/paragon/icons'; +import { selectNotificationTabsCount } from './data/selectors'; +import { resetNotificationState } from './data/thunks'; +import { useIsOnLargeScreen, useIsOnMediumScreen } from './data/hook'; +import NotificationTabs from './NotificationTabs'; +import messages from './messages'; + +const Notifications = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const popoverRef = useRef(null); + const buttonRef = useRef(null); + const [enableNotificationTray, setEnableNotificationTray] = useState(false); + const notificationCounts = useSelector(selectNotificationTabsCount()); + const isOnMediumScreen = useIsOnMediumScreen(); + const isOnLargeScreen = useIsOnLargeScreen(); + + const hideNotificationTray = useCallback(() => { + setEnableNotificationTray(prevState => !prevState); + }, []); + + const handleClickOutsideNotificationTray = useCallback((event) => { + if (!popoverRef.current?.contains(event.target) && !buttonRef.current?.contains(event.target)) { + setEnableNotificationTray(false); + } + }, []); + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutsideNotificationTray); + + return () => { + document.removeEventListener('mousedown', handleClickOutsideNotificationTray); + dispatch(resetNotificationState()); + }; + }, []); + + return ( +