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 }) => ( - -
- + + +
+ + ); describe('
', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: '123abc', + username: 'testuser', + administrator: false, + roles: [], + }, + }); + }); it('renders correctly for anonymous desktop', () => { const contextValue = { authenticatedUser: null, diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx new file mode 100644 index 0000000..8f7aaeb --- /dev/null +++ b/src/Notifications/NotificationRowItem.jsx @@ -0,0 +1,71 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import { Icon } from '@edx/paragon'; +import { Link } from 'react-router-dom'; +import * as timeago from 'timeago.js'; +import { getIconByType } from './utils'; +import { markNotificationsAsRead } from './data/thunks'; +import messages from './messages'; +import timeLocale from '../common/time-locale'; + +const NotificationRowItem = ({ + id, type, contentUrl, content, courseName, createdAt, lastRead, +}) => { + timeago.register('time-locale', timeLocale); + const intl = useIntl(); + const dispatch = useDispatch(); + + const handleMarkAsRead = useCallback(() => { + dispatch(markNotificationsAsRead(id)); + }, [dispatch, id]); + + const { icon: iconComponent, class: iconClass } = getIconByType(type); + + return ( + + +
+
+
+ +
+ + {courseName} + {intl.formatMessage(messages.fullStop)} + {timeago.format(createdAt, 'time-locale')} + +
+
+ {!lastRead && ( +
+ +
+ )} +
+
+ + ); +}; + +NotificationRowItem.propTypes = { + id: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + contentUrl: PropTypes.string.isRequired, + content: PropTypes.node.isRequired, + courseName: PropTypes.string.isRequired, + createdAt: PropTypes.string.isRequired, + lastRead: PropTypes.string.isRequired, +}; + +export default React.memo(NotificationRowItem); diff --git a/src/Notifications/NotificationSections.jsx b/src/Notifications/NotificationSections.jsx new file mode 100644 index 0000000..2048ce5 --- /dev/null +++ b/src/Notifications/NotificationSections.jsx @@ -0,0 +1,81 @@ +import React, { useCallback, useMemo } from 'react'; +import { Button } from '@edx/paragon'; +import { useDispatch, useSelector } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import isEmpty from 'lodash/isEmpty'; +import messages from './messages'; +import NotificationRowItem from './NotificationRowItem'; +import { markAllNotificationsAsRead } from './data/thunks'; +import { selectNotificationsByIds, selectPaginationData, selectSelectedAppName } from './data/selectors'; +import { splitNotificationsByTime } from './utils'; +import { updatePaginationRequest } from './data/slice'; + +const NotificationSections = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const selectedAppName = useSelector(selectSelectedAppName()); + const notifications = useSelector(selectNotificationsByIds(selectedAppName)); + const { currentPage, numPages } = useSelector(selectPaginationData()); + const { today = [], earlier = [] } = useMemo( + () => splitNotificationsByTime(notifications), + [notifications], + ); + + const handleMarkAllAsRead = useCallback(() => { + dispatch(markAllNotificationsAsRead(selectedAppName)); + }, [dispatch, selectedAppName]); + + const updatePagination = useCallback(() => { + dispatch(updatePaginationRequest()); + }, [dispatch]); + + const renderNotificationSection = (section, items) => { + if (isEmpty(items)) { return null; } + + return ( +
+
+ + {section === 'today' && intl.formatMessage(messages.notificationTodayHeading)} + {section === 'earlier' && intl.formatMessage(messages.notificationEarlierHeading)} + + {notifications?.length > 0 && (section === 'earlier' ? today.length === 0 : true) && ( + + )} +
+ {items.map((notification) => ( + + ))} +
+ ); + }; + + return ( +
+ {renderNotificationSection('today', today)} + {renderNotificationSection('earlier', earlier)} + {currentPage < numPages && ( + + )} +
+ ); +}; + +export default React.memo(NotificationSections); diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx new file mode 100644 index 0000000..490ec06 --- /dev/null +++ b/src/Notifications/NotificationTabs.jsx @@ -0,0 +1,52 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Tab, Tabs } from '@edx/paragon'; +import NotificationSections from './NotificationSections'; +import { fetchNotificationList, markNotificationsAsSeen } from './data/thunks'; +import { + selectNotificationTabs, selectNotificationTabsCount, selectPaginationData, selectSelectedAppName, +} from './data/selectors'; +import { updateAppNameRequest } from './data/slice'; + +const NotificationTabs = () => { + const dispatch = useDispatch(); + const selectedAppName = useSelector(selectSelectedAppName()); + const notificationUnseenCounts = useSelector(selectNotificationTabsCount()); + const notificationTabs = useSelector(selectNotificationTabs()); + const { currentPage } = useSelector(selectPaginationData()); + + useEffect(() => { + dispatch(fetchNotificationList({ appName: selectedAppName, page: currentPage, pageSize: 10 })); + if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); } + }, [currentPage, selectedAppName]); + + const handleActiveTab = useCallback((appName) => { + dispatch(updateAppNameRequest({ appName })); + }, []); + + const tabArray = useMemo(() => notificationTabs?.map((appName) => ( + + {appName === selectedAppName && ()} + + )), [notificationUnseenCounts, selectedAppName, notificationTabs]); + + return ( + + {tabArray} + + ); +}; + +export default React.memo(NotificationTabs); diff --git a/src/Notifications/data/api.js b/src/Notifications/data/api.js new file mode 100644 index 0000000..8e7c117 --- /dev/null +++ b/src/Notifications/data/api.js @@ -0,0 +1,40 @@ +import { camelCaseObject } from '@edx/frontend-platform'; +import notificationsList from './notifications.json'; + +export async function getNotifications(appName, page, pageSize) { + const { data } = notificationsList; + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + + const notifications = data.slice(startIndex, endIndex); + return { notifications: camelCaseObject(notifications), numPages: 2, currentPage: page }; +} + +export async function getNotificationCounts() { + const data = { + count: 45, + count_by_app_name: { + reminders: 10, + discussions: 20, + grades: 10, + authoring: 5, + }, + show_notification_tray: false, + }; + return camelCaseObject(data); +} + +export async function markNotificationSeen() { + const data = []; + return camelCaseObject(data); +} + +export async function markAllNotificationRead() { + const { data } = camelCaseObject(notificationsList); + return data; +} + +export async function markNotificationRead(notificationId) { + const { data } = camelCaseObject(notificationsList); + return { data, id: notificationId }; +} diff --git a/src/Notifications/data/hook.js b/src/Notifications/data/hook.js new file mode 100644 index 0000000..b41967a --- /dev/null +++ b/src/Notifications/data/hook.js @@ -0,0 +1,11 @@ +import { breakpoints, useWindowSize } from '@edx/paragon'; + +export function useIsOnMediumScreen() { + const windowSize = useWindowSize(); + return breakpoints.large.maxWidth > windowSize.width && windowSize.width >= breakpoints.medium.minWidth; +} + +export function useIsOnLargeScreen() { + const windowSize = useWindowSize(); + return windowSize.width >= breakpoints.extraLarge.minWidth; +} diff --git a/src/Notifications/data/index.js b/src/Notifications/data/index.js new file mode 100644 index 0000000..4285022 --- /dev/null +++ b/src/Notifications/data/index.js @@ -0,0 +1 @@ +export * from './slice'; diff --git a/src/Notifications/data/notifications.json b/src/Notifications/data/notifications.json new file mode 100644 index 0000000..87e4eb6 --- /dev/null +++ b/src/Notifications/data/notifications.json @@ -0,0 +1,134 @@ +{ + "data": [ + { + "id": 1, + "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": 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 ( + +
+ + {intl.formatMessage(messages.notificationTitle)} + + + + + +
+ + )} + > +
+ + {notificationCounts?.count > 0 && ( + + {notificationCounts.count} + + )} +
+
+ ); +}; + +export default Notifications; diff --git a/src/Notifications/messages.js b/src/Notifications/messages.js new file mode 100644 index 0000000..a26ceed --- /dev/null +++ b/src/Notifications/messages.js @@ -0,0 +1,36 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + notificationTitle: { + id: 'notification.title', + defaultMessage: 'Notifications', + description: 'Notifications', + }, + notificationTodayHeading: { + id: 'notification.today.heading', + defaultMessage: 'Last 24 hours', + description: 'Today Notifications', + }, + notificationEarlierHeading: { + id: 'notification.earlier.heading', + defaultMessage: 'Earlier', + description: 'Earlier Notifications', + }, + notificationMarkAsRead: { + id: 'notification.mark.as.read', + defaultMessage: 'Mark all as read', + description: 'Mark all Notifications as read', + }, + fullStop: { + id: 'notification.fullStop', + defaultMessage: '•', + description: 'Fullstop shown to users to indicate who edited a post.', + }, + loadMoreNotifications: { + id: 'notification.load.more.notifications', + defaultMessage: 'Load more notifications', + description: 'Load more button to load more notifications', + }, +}); + +export default messages; diff --git a/src/Notifications/utils.js b/src/Notifications/utils.js new file mode 100644 index 0000000..a0e99d2 --- /dev/null +++ b/src/Notifications/utils.js @@ -0,0 +1,52 @@ +import { + CheckCircle, HelpOutline, QuestionAnswerOutline, Verified, Report, EditOutline, ThumbUpOutline, PostOutline, +} from '@edx/paragon/icons'; + +/** + * Get HTTP Error status from generic error. + * @param error Generic caught error. + * @returns {number|null} + */ +export const getHttpErrorStatus = error => error?.customAttributes?.httpErrorStatus ?? error?.response?.status; + +export const splitNotificationsByTime = (notificationList) => { + let splittedData = []; + if (notificationList.length > 0) { + const currentTime = Date.now(); + const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000); + + splittedData = notificationList.reduce( + (result, notification) => { + if (notification) { + const objectTime = new Date(notification.createdAt).getTime(); + if (objectTime >= twentyFourHoursAgo && objectTime <= currentTime) { + result.today.push(notification); + } else { + result.earlier.push(notification); + } + } + return result; + }, + { today: [], earlier: [] }, + ); + } + const { today, earlier } = splittedData; + return { today, earlier }; +}; + +export const getIconByType = (type) => { + const iconMap = { + post: { icon: PostOutline, class: 'text-primary-500' }, + help: { icon: HelpOutline, class: 'text-primary-500' }, + respond: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + comment: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + question: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + answer: { icon: CheckCircle, class: 'text-success' }, + endorsed: { icon: Verified, class: 'text-primary-500' }, + reported: { icon: Report, class: 'text-danger-500' }, + postLiked: { icon: ThumbUpOutline, class: 'text-primary-500' }, + commentLiked: { icon: ThumbUpOutline, class: 'text-primary-500' }, + edited: { icon: EditOutline, class: 'text-primary-500' }, + }; + return iconMap[type] || { icon: PostOutline, class: 'text-primary-500' }; +}; diff --git a/src/common/time-locale.js b/src/common/time-locale.js new file mode 100644 index 0000000..4a618dd --- /dev/null +++ b/src/common/time-locale.js @@ -0,0 +1,18 @@ +export default function timeLocale(number, index) { + return [ + ['just now', 'right now'], + ['%ss', 'in %s seconds'], + ['1m', 'in 1 minute'], + ['%sm', 'in %s minutes'], + ['1h', 'in 1 hour'], + ['%sh', 'in %s hours'], + ['1d', 'in 1 day'], + ['%sd', 'in %s days'], + ['1w', 'in 1 week'], + ['%sw', 'in %s weeks'], + ['4w', 'in 1 month'], + [`${number * 4}w`, 'in %s months'], + ['1y', 'in 1 year'], + ['%sy', 'in %s years'], + ][index]; +} diff --git a/src/index.scss b/src/index.scss index 8d5d162..1e1eeca 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,7 +1,10 @@ $spacer: 1rem; $blue: #007db8; $white: #fff; - +@import "@edx/brand/paragon/fonts.scss"; +@import "@edx/brand/paragon/variables.scss"; +@import "@edx/paragon/scss/core/core.scss"; +@import "@edx/brand/paragon/overrides.scss"; @import './Menu/menu.scss'; .dropdown-item a { @@ -27,7 +30,7 @@ $white: #fff; .learning-header { min-width: 0; - + .course-title-lockup { min-width: 0; @@ -118,3 +121,135 @@ $white: #fff; border-radius: $rounded-pill; } } + +.content { + b { + color: #00262B !important; + font-weight: 500 !important; + } +} + +.font-size-18 { + font-size: 18px !important; +} + +.font-size-12 { + font-size: 12px; +} + +.font-size-14 { + font-size: 14px; +} + +.py-10px { + padding-top: 10px; + padding-bottom: 10px; +} + +.pb-10px { + padding-bottom: 10px; +} + +.line-height-24 { + line-height: 24px; +} + +.line-height-20 { + line-height: 20px; +} + +.line-height-10 { + line-height: 10px !important; +} + +.icon-size-20 { + width: 20px !important; + height: 20px !important; +} + +.cursor-pointer { + cursor: pointer; +} + +.notification-button { + width: 36px; + height: 36px; +} + +.notification-icon{ + height: 23.33px !important; + width: 23.33px !important; +} + +.notification-badge { + position: absolute; + margin-top: 18px; + margin-left: -21px; + border: 2px solid #FFFFFF; + font-size: 9px !important; +} + +.popover { + max-height: calc(100% - 68px); + min-height: 1220px; + filter: none; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), 0px 2px 8px rgba(0, 0, 0, 0.15); + + &.medium-screen { + min-width: 24.313rem; + } + + &.large-screen { + min-width: 34.313rem; + } + + .dropdown-toggle::after { + display: none; + } + + .expandable { + position: relative !important; + margin-left: 4px; + padding: 2px 5px; + border-radius: 10rem; + font-size: 9px; + } + + .dropdown-toggle { + font-size: 14px; + padding-top: 0px !important; + padding-bottom: 12px !important; + + div { + min-height: 6px !important; + min-width: 6px !important; + } + } + + .dropdown-item { + font-size: 14px; + font-weight: 500; + } + + .notification-content { + .notification-item-content { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; + + p { + margin-bottom: 0px; + } + + b { + color: #00262B; + } + } + + .unread { + height: 10px; + width: 10px; + } + } +} diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index ea6f213..2ac7beb 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -1,16 +1,31 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUserCircle } from '@fortawesome/free-solid-svg-icons'; - import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Dropdown } from '@edx/paragon'; +import { useSelector, useDispatch } from 'react-redux'; +import Notifications from '../Notifications'; +import { selectShowNotificationTray, selectNotificationStatus } from '../Notifications/data/selectors'; +import { fetchAppsNotificationCount } from '../Notifications/data/thunks'; +import { RequestStatus } from '../Notifications/data/slice'; import messages from './messages'; const AuthenticatedUserDropdown = ({ intl, username }) => { + const showNotificationTray = useSelector(selectShowNotificationTray()); + const notificationStatus = useSelector(selectNotificationStatus()); + const dispatch = useDispatch(); + + useEffect(() => { + if (notificationStatus === RequestStatus.IDLE) { + dispatch(fetchAppsNotificationCount()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [notificationStatus]); + const dashboardMenuItem = ( {intl.formatMessage(messages.dashboard)} @@ -19,8 +34,9 @@ const AuthenticatedUserDropdown = ({ intl, username }) => { return ( <> - {intl.formatMessage(messages.help)} - + {intl.formatMessage(messages.help)} + {showNotificationTray && } + diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index bf266fb..32356f0 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -2,11 +2,12 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { AppContext } from '@edx/frontend-platform/react'; +import { AppContext, AppProvider } from '@edx/frontend-platform/react'; import AnonymousUserMenu from './AnonymousUserMenu'; import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; import messages from './messages'; +import store from '../store'; const LinkedLogo = ({ href, @@ -40,24 +41,26 @@ const LearningHeader = ({ ); return ( -
- {intl.formatMessage(messages.skipNavLink)} -
- {headerLogo} -
- {courseOrg} {courseNumber} - {courseTitle} + +
+ {intl.formatMessage(messages.skipNavLink)} +
+ {headerLogo} +
+ {courseOrg} {courseNumber} + {courseTitle} +
+ {showUserDropdown && authenticatedUser && ( + + )} + {showUserDropdown && !authenticatedUser && ( + + )}
- {showUserDropdown && authenticatedUser && ( - - )} - {showUserDropdown && !authenticatedUser && ( - - )} -
-
+
+ ); }; diff --git a/src/learning-header/LearningHeader.test.jsx b/src/learning-header/LearningHeader.test.jsx index 8937e5d..3d80888 100644 --- a/src/learning-header/LearningHeader.test.jsx +++ b/src/learning-header/LearningHeader.test.jsx @@ -12,7 +12,7 @@ describe('Header', () => { it('displays user button', () => { render(
); - expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username); + expect(screen.getByText(authenticatedUser.username)).toBeInTheDocument(); }); it('displays course data', () => { diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..342022c --- /dev/null +++ b/src/store.js @@ -0,0 +1,16 @@ +import { configureStore } from '@reduxjs/toolkit'; + +import { notificationsReducer } from './Notifications/data'; + +export function initializeStore(preloadedState = undefined) { + return configureStore({ + reducer: { + notifications: notificationsReducer, + }, + preloadedState, + }); +} + +const store = initializeStore(); + +export default store;