From a5069edd94e12315a7a0efed962f951310593c6a Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Mon, 15 May 2023 16:36:41 +0500 Subject: [PATCH 01/24] feat: added notification UI --- package-lock.json | 16 +-- package.json | 4 +- src/DesktopHeader.jsx | 8 ++ src/Header.jsx | 24 ++++ src/Notifications/NotificationIcon.jsx | 90 +++++++++++++++ src/Notifications/NotificationRow.jsx | 29 +++++ src/Notifications/NotificationRowItem.jsx | 69 ++++++++++++ src/Notifications/data/api.js | 38 +++++++ src/Notifications/icons/HelpOutline.jsx | 26 +++++ src/Notifications/icons/PostOutline.jsx | 18 +++ src/Notifications/icons/index.js | 2 + src/Notifications/messages.js | 25 +++++ src/index.scss | 128 +++++++++++++++++++++- 13 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 src/Notifications/NotificationIcon.jsx create mode 100644 src/Notifications/NotificationRow.jsx create mode 100644 src/Notifications/NotificationRowItem.jsx create mode 100644 src/Notifications/data/api.js create mode 100644 src/Notifications/icons/HelpOutline.jsx create mode 100644 src/Notifications/icons/PostOutline.jsx create mode 100644 src/Notifications/icons/index.js create mode 100644 src/Notifications/messages.js diff --git a/package-lock.json b/package-lock.json index 0e41f33..8817521 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0-semantically-released", "license": "AGPL-3.0", "dependencies": { - "@edx/paragon": "20.36.0", + "@edx/paragon": "20.34.0", "@fortawesome/fontawesome-svg-core": "6.3.0", "@fortawesome/free-brands-svg-icons": "6.3.0", "@fortawesome/free-regular-svg-icons": "6.3.0", @@ -25,7 +25,7 @@ "@edx/frontend-build": "12.8.27", "@edx/frontend-platform": "4.2.0", "@edx/reactifex": "^2.1.1", - "@testing-library/dom": "9.3.0", + "@testing-library/dom": "9.2.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "10.4.9", "enzyme": "3.11.0", @@ -3144,9 +3144,9 @@ } }, "node_modules/@edx/paragon": { - "version": "20.36.0", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.36.0.tgz", - "integrity": "sha512-0dn4r1HvcrHY66xmLkLTRIBD09TDrNn6vxWu1XZr2SwkGLf56cI8aGkZEySeOVs/VLWtJRMmMJaSbozCpxvLyg==", + "version": "20.34.0", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.34.0.tgz", + "integrity": "sha512-elWDy17qAHsORsqhAyp1SFOmnwKqvgHJrOvoZnw03xXUeHMn7m4j5aH3UIAHHYV/xu2au4g0YSnwni/+KDLP2A==", "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", @@ -6000,9 +6000,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.0.tgz", - "integrity": "sha512-Dffe68pGwI6WlLRYR2I0piIkyole9cSBH5jGQKCGMRpHW5RHCqAUaqc2Kv0tUyd4dU4DLPKhJIjyKOnjv4tuUw==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.2.0.tgz", + "integrity": "sha512-xTEnpUKiV/bMyEsE5bT4oYA0x0Z/colMtxzUY8bKyPXBNLn/e0V4ZjBZkEhms0xE4pv9QsPfSRu9AWS4y5wGvA==", "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", diff --git a/package.json b/package.json index 0565551..f3ccc59 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@edx/frontend-build": "12.8.27", "@edx/frontend-platform": "4.2.0", "@edx/reactifex": "^2.1.1", - "@testing-library/dom": "9.3.0", + "@testing-library/dom": "9.2.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "10.4.9", "enzyme": "3.11.0", @@ -56,7 +56,7 @@ "redux-saga": "1.2.3" }, "dependencies": { - "@edx/paragon": "20.36.0", + "@edx/paragon": "20.34.0", "@fortawesome/fontawesome-svg-core": "6.3.0", "@fortawesome/free-brands-svg-icons": "6.3.0", "@fortawesome/free-regular-svg-icons": "6.3.0", diff --git a/src/DesktopHeader.jsx b/src/DesktopHeader.jsx index 8bd142f..d7d5b62 100644 --- a/src/DesktopHeader.jsx +++ b/src/DesktopHeader.jsx @@ -13,6 +13,7 @@ import messages from './Header.messages'; // Assets import { CaretIcon } from './Icons'; +import NotificationIcon from './Notifications/NotificationIcon'; class DesktopHeader extends React.Component { constructor(props) { // eslint-disable-line no-useless-constructor @@ -121,6 +122,7 @@ class DesktopHeader extends React.Component { loggedIn, intl, appMenu, + notificationCounts, } = this.props; const logoProps = { src: logo, alt: logoAltText, href: logoDestination }; const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null; @@ -149,6 +151,7 @@ class DesktopHeader extends React.Component { aria-label={intl.formatMessage(messages['header.label.secondary.nav'])} className="nav secondary-menu-container align-items-center ml-auto" > + {loggedIn && } {loggedIn ? this.renderUserMenu() : this.renderLoggedOutItems()} @@ -179,6 +182,10 @@ DesktopHeader.propTypes = { avatar: PropTypes.string, username: PropTypes.string, loggedIn: PropTypes.bool, + notificationCounts: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.string, + count: PropTypes.string, + })), // i18n intl: intlShape.isRequired, @@ -209,6 +216,7 @@ DesktopHeader.defaultProps = { username: null, loggedIn: false, appMenu: null, + notificationCounts: [], }; export default injectIntl(DesktopHeader); diff --git a/src/Header.jsx b/src/Header.jsx index c0db257..da435a0 100644 --- a/src/Header.jsx +++ b/src/Header.jsx @@ -88,6 +88,29 @@ const Header = ({ intl }) => { }, ]; + const notificationCounts = [ + { + type: 'total', + count: 25, + }, + { + type: 'reminders', + count: 1, + }, + { + type: 'discussions', + count: 0, + }, + { + type: 'grades', + count: 0, + }, + { + type: 'authoring', + count: 24, + }, + ]; + const props = { logo: config.LOGO_URL, logoAltText: config.SITE_NAME, @@ -98,6 +121,7 @@ const Header = ({ intl }) => { mainMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : mainMenu, userMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : userMenu, loggedOutItems: getConfig().AUTHN_MINIMAL_HEADER ? [] : loggedOutItems, + notificationCounts, }; return ( diff --git a/src/Notifications/NotificationIcon.jsx b/src/Notifications/NotificationIcon.jsx new file mode 100644 index 0000000..85ab5cf --- /dev/null +++ b/src/Notifications/NotificationIcon.jsx @@ -0,0 +1,90 @@ +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { NotificationsNone, Settings } from '@edx/paragon/icons'; +import { + Tabs, Tab, Badge, Form, Icon, IconButton, OverlayTrigger, Popover, +} from '@edx/paragon'; +import NotificationRow from './NotificationRow'; + +const NotificationIcon = ({ notificationCounts }) => { + const [showNotificationTray, setShowNotificationTray] = useState(false); + + const handleNotificationTray = useCallback((value) => { + setShowNotificationTray(value); + }, []); + + return ( +
+ + +

Notifications

+
+ +
+
+ + + + Hello I am the first panel. + + + + + + Hello I am the third panel. + + + Hello I am the fourth panel. + + + Hello I am the fifth panel. + + + Hello I am the sixth panel. + + + + + )} + > + <> + + {notificationCounts[0]?.count} + +
+ { handleNotificationTray(!showNotificationTray); }} + onBlur={() => { handleNotificationTray(false); }} + src={NotificationsNone} + iconAs={Icon} + className="d-inline-block align-bottom ml-1 bell-icon" + /> +
+ +
+
+ ); +}; + +NotificationIcon.propTypes = { + notificationCounts: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.string, + count: PropTypes.string, + })), +}; + +NotificationIcon.defaultProps = { + notificationCounts: [], +}; +export default NotificationIcon; diff --git a/src/Notifications/NotificationRow.jsx b/src/Notifications/NotificationRow.jsx new file mode 100644 index 0000000..9618ab8 --- /dev/null +++ b/src/Notifications/NotificationRow.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { messages } from './messages'; +import NotificationRowItem from './NotificationRowItem'; + +const NotificationRow = () => { + const intl = useIntl(); + + return ( +
+
+ + {intl.formatMessage(messages.notificationTodayHeading)} + + + {intl.formatMessage(messages.notificationMarkAsRead)} + +
+
+ +
+
+ ); +}; + +NotificationRow.propTypes = { +}; + +export default React.memo(NotificationRow); diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx new file mode 100644 index 0000000..bd6688d --- /dev/null +++ b/src/Notifications/NotificationRowItem.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +// import * as timeago from 'timeago.js'; +import { messages } from './messages'; +import { PostOutline } from './icons'; + +const NotificationRowItem = () => { + const intl = useIntl(); + + return ( +
+
+ +
+
+
+ + SCM_Lead posted + + Hello and welcome to SC0x! + + +
+
+
+
+ {/*
+ + Supply Chain Analytics + + {intl.formatMessage(messages.fullStop)} + + + {timeago.format(postCreatedAt, 'time-locale')} + + + + +
*/} +
+
+ ); +}; + +NotificationRowItem.propTypes = { +}; + +export default React.memo(NotificationRowItem); diff --git a/src/Notifications/data/api.js b/src/Notifications/data/api.js new file mode 100644 index 0000000..18398ba --- /dev/null +++ b/src/Notifications/data/api.js @@ -0,0 +1,38 @@ +/* eslint-disable import/prefer-default-export */ +// import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +export const getApiBaseUrl = () => getConfig().LMS_BASE_URL; + +export async function getCourseTopics() { + // const url = `${getApiBaseUrl()}/api/discussion/v1/notifications/`; + + // const { data } = await getAuthenticatedHttpClient() + // .get(url); + + const data = [{ + TODAY: [ + { + type: 'post', + respondingUser: 'SCM_Lead', + notificationContent: 'Hello and welcome to SC0x!', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + }, + { + type: 'help', + respondingUser: 'MITx_Learner', + notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + }, + ], + }]; + return data; +} diff --git a/src/Notifications/icons/HelpOutline.jsx b/src/Notifications/icons/HelpOutline.jsx new file mode 100644 index 0000000..53cdd48 --- /dev/null +++ b/src/Notifications/icons/HelpOutline.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +const HelpOutline = () => ( + + + + + + + + + + + +); + +export default HelpOutline; diff --git a/src/Notifications/icons/PostOutline.jsx b/src/Notifications/icons/PostOutline.jsx new file mode 100644 index 0000000..a7bb4d4 --- /dev/null +++ b/src/Notifications/icons/PostOutline.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +const PostOutline = () => ( + + + +); + +export default PostOutline; diff --git a/src/Notifications/icons/index.js b/src/Notifications/icons/index.js new file mode 100644 index 0000000..c27b06d --- /dev/null +++ b/src/Notifications/icons/index.js @@ -0,0 +1,2 @@ +export { default as PostOutline } from './PostOutline'; +export { default as HelpOutline } from './HelpOutline'; diff --git a/src/Notifications/messages.js b/src/Notifications/messages.js new file mode 100644 index 0000000..e7a3db8 --- /dev/null +++ b/src/Notifications/messages.js @@ -0,0 +1,25 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +// eslint-disable-next-line import/prefer-default-export +export const messages = defineMessages({ + notificationTodayHeading: { + id: 'notification.today.heading', + defaultMessage: 'Today', + description: 'Today Notifications', + }, + notificationMarkAsRead: { + id: 'notification.mark.as.read', + defaultMessage: 'Mark all as read', + description: 'Mark all Notifications as read', + }, + notificationPostedContent: { + id: 'notification.posted.content', + defaultMessage: '{respondingUser} posted {notificationContent}', + description: 'Display notification content for post type', + }, + notificationHelpedContent: { + id: 'notification.helped.content', + defaultMessage: '{respondingUser} asked {notificationContent}', + description: 'Display notification content for help type', + }, +}); diff --git a/src/index.scss b/src/index.scss index 8d5d162..ae08e2c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -27,7 +27,7 @@ $white: #fff; .learning-header { min-width: 0; - + .course-title-lockup { min-width: 0; @@ -118,3 +118,129 @@ $white: #fff; border-radius: $rounded-pill; } } + +.popover .arrow{ + display: none !important; + } +.notification-title{ + line-height: 24px; + font-weight: 700; + font-size: 18px; +} +.setting-icon-container{ + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: flex-end; + width: 100%; + position: absolute; + margin-left: -40px; + margin-top: -7px; + span{ + height:20px; + width: 20px; + } +} + +.notification-content{ + padding-left: 0px; + width: 549px; + } +.notification-tabs{ + height: 38px; + width:549px; + padding-left: 12px; + button{ + font-size: 14px; + } + .dropdown-toggle{ + height: 36px; + padding-top: 0px !important; + padding-left: 12px !important; + padding-right: 26px !important; + + div{ + margin-top: 4px; + height: 20px; + width: 20px; + } + } + .dropdown{ + height: 36px; + } + .notification-tab, .dropdown-item{ + display: flex; + flex-direction: row; + align-items: center; + padding: 0px 12px 12px !important; + height: 36px; + font-size:14px; + font-weight: 500; + line-height: 24px; + +} +.expandable{ + height: 20px; + width: 20px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 6px 7px; + gap: 8px; + position: relative; + margin-left: 4px; + } +} + +.bell-container{ + + button{ + position: relative; + background: transparent; + color: black; + border: none; + &:hover, &:active, &:focus{ + background: transparent !important; + color: black !important; + border: none; + } + &::before{ + border: none !important + } + } + .bell-icon-container{ + width: 36px; + height: 36px; + border-radius: 1e+16px; + color: black !important; + &:hover{ + background: #EAE6E5; + } + .bell-icon{ + margin-left: -4px !important; + &:focus{ + box-shadow: none !important + } + } + + } + .badge{ + z-index: 1; + border-radius: 54px; + border: 2px solid #FFFFFF; + padding: 4px 5px; + width: 23px; + height: 16px; + margin-top: 3px; + margin-left: 20px; + .count{ + font-size: 9px; + line-height: 20px; + width: 13px; + height: 8px; + font-weight: 600; + margin-top: -3px; + } + } +} From f8fc7944585038289899573890786f9f065d5260 Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Fri, 19 May 2023 16:17:50 +0500 Subject: [PATCH 02/24] feat: added notification tray --- example/index.js | 4 +- package-lock.json | 53 ++++- package.json | 5 +- src/DesktopHeader.jsx | 10 +- src/Header.jsx | 25 --- src/Notifications/NotificationIcon.jsx | 90 -------- src/Notifications/NotificationRow.jsx | 29 --- src/Notifications/NotificationRowItem.jsx | 128 +++++++---- src/Notifications/NotificationSections.jsx | 41 ++++ src/Notifications/NotificationTabs.jsx | 53 +++++ src/Notifications/Notifications.jsx | 77 +++++++ src/Notifications/data/api.js | 212 +++++++++++++++--- src/Notifications/data/index.js | 1 + src/Notifications/data/selectors.js | 3 + src/Notifications/data/slice.js | 62 +++++ src/Notifications/data/thunks.js | 36 +++ src/Notifications/icons/CheckCircleFilled.jsx | 19 ++ src/Notifications/icons/EditOutline.jsx | 19 ++ .../icons/QuestionAnswerOutline.jsx | 19 ++ src/Notifications/icons/Report.jsx | 19 ++ src/Notifications/icons/ThumbsUpOutline.jsx | 22 ++ src/Notifications/icons/Verified.jsx | 19 ++ src/Notifications/icons/index.js | 6 + src/Notifications/messages.js | 79 ++++++- src/constants.js | 37 +++ src/index.scss | 190 +++++++++------- src/store.js | 16 ++ src/time-locale.js | 19 ++ 28 files changed, 981 insertions(+), 312 deletions(-) delete mode 100644 src/Notifications/NotificationIcon.jsx delete mode 100644 src/Notifications/NotificationRow.jsx create mode 100644 src/Notifications/NotificationSections.jsx create mode 100644 src/Notifications/NotificationTabs.jsx create mode 100644 src/Notifications/Notifications.jsx create mode 100644 src/Notifications/data/index.js create mode 100644 src/Notifications/data/selectors.js create mode 100644 src/Notifications/data/slice.js create mode 100644 src/Notifications/data/thunks.js create mode 100644 src/Notifications/icons/CheckCircleFilled.jsx create mode 100644 src/Notifications/icons/EditOutline.jsx create mode 100644 src/Notifications/icons/QuestionAnswerOutline.jsx create mode 100644 src/Notifications/icons/Report.jsx create mode 100644 src/Notifications/icons/ThumbsUpOutline.jsx create mode 100644 src/Notifications/icons/Verified.jsx create mode 100644 src/constants.js create mode 100644 src/store.js create mode 100644 src/time-locale.js diff --git a/example/index.js b/example/index.js index e9b44a5..5a93ed0 100644 --- a/example/index.js +++ b/example/index.js @@ -5,12 +5,14 @@ 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 store from '../src/store'; + import './index.scss'; subscribe(APP_READY, () => { ReactDOM.render( - + {/* We can fake out authentication by including another provider here with the data we want */} - {loggedIn && } + {loggedIn && } {loggedIn ? this.renderUserMenu() : this.renderLoggedOutItems()}
@@ -182,10 +181,6 @@ DesktopHeader.propTypes = { avatar: PropTypes.string, username: PropTypes.string, loggedIn: PropTypes.bool, - notificationCounts: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.string, - count: PropTypes.string, - })), // i18n intl: intlShape.isRequired, @@ -216,7 +211,6 @@ DesktopHeader.defaultProps = { username: null, loggedIn: false, appMenu: null, - notificationCounts: [], }; export default injectIntl(DesktopHeader); diff --git a/src/Header.jsx b/src/Header.jsx index da435a0..97c094e 100644 --- a/src/Header.jsx +++ b/src/Header.jsx @@ -9,7 +9,6 @@ import { getConfig, subscribe, } from '@edx/frontend-platform'; - import DesktopHeader from './DesktopHeader'; import MobileHeader from './MobileHeader'; @@ -88,29 +87,6 @@ const Header = ({ intl }) => { }, ]; - const notificationCounts = [ - { - type: 'total', - count: 25, - }, - { - type: 'reminders', - count: 1, - }, - { - type: 'discussions', - count: 0, - }, - { - type: 'grades', - count: 0, - }, - { - type: 'authoring', - count: 24, - }, - ]; - const props = { logo: config.LOGO_URL, logoAltText: config.SITE_NAME, @@ -121,7 +97,6 @@ const Header = ({ intl }) => { mainMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : mainMenu, userMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : userMenu, loggedOutItems: getConfig().AUTHN_MINIMAL_HEADER ? [] : loggedOutItems, - notificationCounts, }; return ( diff --git a/src/Notifications/NotificationIcon.jsx b/src/Notifications/NotificationIcon.jsx deleted file mode 100644 index 85ab5cf..0000000 --- a/src/Notifications/NotificationIcon.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import PropTypes from 'prop-types'; -import { NotificationsNone, Settings } from '@edx/paragon/icons'; -import { - Tabs, Tab, Badge, Form, Icon, IconButton, OverlayTrigger, Popover, -} from '@edx/paragon'; -import NotificationRow from './NotificationRow'; - -const NotificationIcon = ({ notificationCounts }) => { - const [showNotificationTray, setShowNotificationTray] = useState(false); - - const handleNotificationTray = useCallback((value) => { - setShowNotificationTray(value); - }, []); - - return ( -
- - -

Notifications

-
- -
-
- - - - Hello I am the first panel. - - - - - - Hello I am the third panel. - - - Hello I am the fourth panel. - - - Hello I am the fifth panel. - - - Hello I am the sixth panel. - - - - - )} - > - <> - - {notificationCounts[0]?.count} - -
- { handleNotificationTray(!showNotificationTray); }} - onBlur={() => { handleNotificationTray(false); }} - src={NotificationsNone} - iconAs={Icon} - className="d-inline-block align-bottom ml-1 bell-icon" - /> -
- -
-
- ); -}; - -NotificationIcon.propTypes = { - notificationCounts: PropTypes.arrayOf(PropTypes.shape({ - type: PropTypes.string, - count: PropTypes.string, - })), -}; - -NotificationIcon.defaultProps = { - notificationCounts: [], -}; -export default NotificationIcon; diff --git a/src/Notifications/NotificationRow.jsx b/src/Notifications/NotificationRow.jsx deleted file mode 100644 index 9618ab8..0000000 --- a/src/Notifications/NotificationRow.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { messages } from './messages'; -import NotificationRowItem from './NotificationRowItem'; - -const NotificationRow = () => { - const intl = useIntl(); - - return ( -
-
- - {intl.formatMessage(messages.notificationTodayHeading)} - - - {intl.formatMessage(messages.notificationMarkAsRead)} - -
-
- -
-
- ); -}; - -NotificationRow.propTypes = { -}; - -export default React.memo(NotificationRow); diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx index bd6688d..b8c97d3 100644 --- a/src/Notifications/NotificationRowItem.jsx +++ b/src/Notifications/NotificationRowItem.jsx @@ -1,69 +1,107 @@ -import React from 'react'; +/* eslint-disable react/forbid-prop-types */ +import React, { useCallback, useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@edx/paragon'; -// import * as timeago from 'timeago.js'; +import * as timeago from 'timeago.js'; +import PropTypes from 'prop-types'; +import { AppContext } from '@edx/frontend-platform/react'; import { messages } from './messages'; -import { PostOutline } from './icons'; +import { + PostOutline, HelpOutline, QuestionAnswerOutline, CheckCircleFilled, Verified, Report, ThumbsUpOutline, EditOutline, +} from './icons'; +import timeLocale from '../time-locale'; -const NotificationRowItem = () => { +const NotificationRowItem = ({ notification }) => { const intl = useIntl(); + timeago.register('time-locale', timeLocale); + const { authenticatedUser } = useContext(AppContext); + + const handleIconByType = (type) => { + switch (type) { + case 'post': return PostOutline; + case 'help': return HelpOutline; + case 'respond': return QuestionAnswerOutline; + case 'comment': return QuestionAnswerOutline; + case 'question': return QuestionAnswerOutline; + case 'answer': return CheckCircleFilled; + case 'endorsed': return Verified; + case 'reported': return Report; + case 'postLiked': return ThumbsUpOutline; + case 'commentLiked': return ThumbsUpOutline; + case 'edited': return EditOutline; + default: return null; + } + }; + + const getContentMessageByType = useCallback(() => { + const typeMap = { + post: messages.notificationPostedContent, + help: messages.notificationHelpedContent, + respond: authenticatedUser.username !== notification.author + ? messages.notificationResponseOnOtherPostLabel : null, + comment: notification.targetUser + ? messages.notificationCommentedOnLabel : messages.notificationCommentedOnYourPostLabel, + question: messages.notificationQuestionLabel, + answer: messages.notificationAnswerLabel, + endorsed: messages.notificationEndorsedLabel, + reported: messages.notificationReportedLabel, + postLiked: messages.notificationPostLikedLabel, + commentLiked: messages.notificationCommentLikedLabel, + edited: messages.notificationEditedLabel, + }; + return typeMap[notification.type] ? intl.formatMessage(typeMap[notification.type]) : null; + }, [authenticatedUser, notification, intl]); return ( -
-
+
+
-
-
- - SCM_Lead posted - - Hello and welcome to SC0x! +
+
+ + {notification?.respondingUser} {' '} + {getContentMessageByType()} + {notification?.targetUser && ( + <> + {notification?.targetUser} + + {authenticatedUser.username !== notification.author + ? intl.formatMessage(messages.notificationResponseOnOtherPostLabel) + : intl.formatMessage(messages.notificationResponseOnYourPostLabel)} + + + )} + + {' '}{notification?.notificationContent} -
-
+
+ {notification.status === 'unread' + &&
} +
+
+ + {notification?.courseName} + + {intl.formatMessage(messages.fullStop)} + + + {timeago.format(notification?.time, 'time-locale')} + +
- {/*
- - Supply Chain Analytics - - {intl.formatMessage(messages.fullStop)} - - - {timeago.format(postCreatedAt, 'time-locale')} - - - - -
*/}
); }; NotificationRowItem.propTypes = { + notification: PropTypes.object.isRequired, }; export default React.memo(NotificationRowItem); diff --git a/src/Notifications/NotificationSections.jsx b/src/Notifications/NotificationSections.jsx new file mode 100644 index 0000000..18b79ff --- /dev/null +++ b/src/Notifications/NotificationSections.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useSelector } from 'react-redux'; +import { messages } from './messages'; +import NotificationRowItem from './NotificationRowItem'; +import { getNotifications } from './data/selectors'; + +const NotificationSections = () => { + const intl = useIntl(); + const notifications = useSelector(getNotifications()); + + return ( + notifications && ( +
+
+ + {notifications && notifications.TODAY && intl.formatMessage(messages.notificationTodayHeading)} + + + {intl.formatMessage(messages.notificationMarkAsRead)} + +
+
+ {notifications && notifications.TODAY && notifications?.TODAY.map( + (notification) => , + )} +
+ + {notifications && notifications.EARLIER && intl.formatMessage(messages.notificationEarlierHeading)} + +
+ {notifications && notifications.EARLIER && notifications?.EARLIER.map( + (notification) => , + )} +
+
+ ) + ); +}; + +export default React.memo(NotificationSections); diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx new file mode 100644 index 0000000..0669c70 --- /dev/null +++ b/src/Notifications/NotificationTabs.jsx @@ -0,0 +1,53 @@ +import React, { + useState, useCallback, useMemo, useEffect, +} from 'react'; +import { Tabs, Tab } from '@edx/paragon'; +import { useSelector, useDispatch } from 'react-redux'; +import NotificationSections from './NotificationSections'; +import { getNotificationTotalUnseenCounts } from './data/selectors'; +import { fetchNotificationList } from './data/thunks'; +import { notificationTabsOptions } from '../constants'; + +const NotificationTabs = () => { + const notificationUnseenCounts = useSelector(getNotificationTotalUnseenCounts()); + const [activeTab, setActiveTab] = useState(notificationTabsOptions[0].key); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchNotificationList({ notificationType: activeTab || 'reminders' })); + }, [dispatch, activeTab]); + + useEffect(() => { + setActiveTab(activeTab || 'reminders'); + }, [activeTab]); + + const handleActiveTab = useCallback((tab) => { + setActiveTab(tab); + }, []); + + const tabArray = useMemo(() => notificationTabsOptions.map((option) => ( + + + + )), [notificationUnseenCounts]); + + return ( + activeTab && ( + + {tabArray} + + ) + ); +}; + +export default NotificationTabs; diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/Notifications.jsx new file mode 100644 index 0000000..f246265 --- /dev/null +++ b/src/Notifications/Notifications.jsx @@ -0,0 +1,77 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { NotificationsNone, Settings } from '@edx/paragon/icons'; +import { + Badge, Form, Icon, IconButton, OverlayTrigger, Popover, +} from '@edx/paragon'; +import { useSelector, useDispatch } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import NotificationTabs from './NotificationTabs'; +import { getNotificationTotalUnseenCounts, getNotificationStatus } from './data/selectors'; +import { fetchNotificationsCountsList } from './data/thunks'; +import { messages } from './messages'; + +const Notifications = () => { + const [showNotificationTray, setShowNotificationTray] = useState(false); + const notificationCounts = useSelector(getNotificationTotalUnseenCounts()); + const intl = useIntl(); + + const dispatch = useDispatch(); + const notificationStatus = useSelector(getNotificationStatus()); + + useEffect(() => { + if (notificationStatus === 'idle') { + dispatch(fetchNotificationsCountsList()); + } + }, [dispatch, notificationStatus]); + + const handleNotificationTray = useCallback((value) => { + setShowNotificationTray(value); + }, []); + + return ( +
+ + +

+ {intl.formatMessage(messages.notificationTitle)} +

+
+ +
+
+ + + + + )} + > + <> + {notificationCounts?.Total > 0 && ( + + {notificationCounts?.Total} + + )} +
+ { handleNotificationTray(!showNotificationTray); }} + src={NotificationsNone} + iconAs={Icon} + className="d-inline-block align-bottom ml-1 bell-icon" + /> +
+ +
+
+ ); +}; + +export default Notifications; diff --git a/src/Notifications/data/api.js b/src/Notifications/data/api.js index 18398ba..67a5198 100644 --- a/src/Notifications/data/api.js +++ b/src/Notifications/data/api.js @@ -4,35 +4,197 @@ import { getConfig } from '@edx/frontend-platform'; export const getApiBaseUrl = () => getConfig().LMS_BASE_URL; -export async function getCourseTopics() { +export async function getNotifications(notificationType) { // const url = `${getApiBaseUrl()}/api/discussion/v1/notifications/`; // const { data } = await getAuthenticatedHttpClient() // .get(url); - const data = [{ - TODAY: [ - { - type: 'post', - respondingUser: 'SCM_Lead', - notificationContent: 'Hello and welcome to SC0x!', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '15m', - }, - { - type: 'help', - respondingUser: 'MITx_Learner', - notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '15m', - }, - ], - }]; + const data = { + discussions: { + TODAY: [ + { + type: 'post', + respondingUser: 'SCM_Lead', + notificationContent: 'Hello and welcome to SC0x!', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + }, + { + type: 'help', + respondingUser: 'MITx_Learner', + notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + }, + ], + }, + reminders: { + TODAY: [ + { + type: 'post', + respondingUser: 'SCM_Lead', + notificationContent: 'Hello and welcome to SC0x!', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '1684253634808', + author: '', + }, + { + type: 'help', + respondingUser: 'MITx_Learner', + notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '1684253736371', + author: '', + }, + { + type: 'respond', + respondingUser: 'MITx_Learner', + notificationContent: 'Can’t find linear regression in section 3 review', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '1684253736371', + author: '', + }, + { + type: 'comment', + respondingUser: 'MITx_Learner', + notificationContent: 'Can’t find linear regression in section 3 review', + targetUser: 'MITx_Expert’s ', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '1684253736371', + author: '', + }, + { + type: 'question', + respondingUser: 'MITx_Learner', + notificationContent: 'Examples of quadratic equations in supply chains', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '1684253736371', + author: '', + }, + { + type: 'comment', + respondingUser: 'MITx_Learner', + notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', + targetUser: 'MITx_Expert’s ', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '1684253736371', + author: 'testuser', + }, + { + type: 'comment', + respondingUser: 'MITx_Learner', + notificationContent: 'Convexity of f(x)=1/x , x>1', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '1684253736371', + author: 'testuser', + }, + ], + EARLIER: [ + { + type: 'answer', + respondingUser: 'SCM_Lead', + notificationContent: 'Quiz in section 3 - Please explain the F-Significance value', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + author: 'testuser', + }, + { + type: 'endorsed', + respondingUser: '', + notificationContent: 'Quiz in section 3 - Please explain the F-Significance value', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + author: 'testuser', + }, + { + type: 'reported', + respondingUser: 'MITx Learner’s', + notificationContent: '“Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + author: '', + }, + { + type: 'postLiked', + respondingUser: 'SCM_Lead', + notificationContent: 'Retaking the course', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + author: '', + }, + { + type: 'commentLiked', + respondingUser: 'MITx_Expert ', + notificationContent: 'Final exam answers', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + author: '', + }, + { + type: 'edited', + respondingUser: 'MITx_Expert ', + notificationContent: 'Question 1', + targetUser: '', + courseName: 'Supply Chain Analytics', + URL: '', + status: 'unread', + time: '15m', + author: '', + }, + ], + }, + }; + return data[notificationType]; +} + +export async function getNotificationCounts() { + const data = { + Total: 25, + Reminders: 10, + Discussions: 5, + Grades: 4, + Authoring: 6, + }; return data; } 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/selectors.js b/src/Notifications/data/selectors.js new file mode 100644 index 0000000..b0bb9d4 --- /dev/null +++ b/src/Notifications/data/selectors.js @@ -0,0 +1,3 @@ +export const getNotificationStatus = () => state => state.notifications.notificationStatus; +export const getNotificationTotalUnseenCounts = () => state => state.notifications.totalUnseenCounts; +export const getNotifications = () => state => state.notifications.notifications; diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js new file mode 100644 index 0000000..b302f99 --- /dev/null +++ b/src/Notifications/data/slice.js @@ -0,0 +1,62 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +export const LOADING = 'loading'; +export const LOADED = 'loaded'; +export const FAILED = 'failed'; +export const DENIED = 'denied'; + +const slice = createSlice({ + name: 'notifications', + initialState: { + notificationStatus: 'idle', + notifications: {}, + totalUnseenCounts: {}, + notificationType: '', + }, + reducers: { + fetchNotificationDenied: (state, { payload }) => { + state.notificationType = payload.notificationType; + state.notificationStatus = DENIED; + }, + fetchNotificationFailure: (state, { payload }) => { + state.notificationType = payload.notificationType; + state.notificationStatus = FAILED; + }, + fetchNotificationRequest: (state, { payload }) => { + state.notificationType = payload.notificationType; + state.notificationStatus = LOADING; + }, + fetchNotificationSuccess: (state, { payload }) => { + state.notifications = payload; + state.notificationStatus = LOADED; + }, + fetchNotificationsCountDenied: (state) => { + state.notificationStatus = DENIED; + }, + fetchNotificationsCountFailure: (state) => { + state.notificationStatus = FAILED; + }, + fetchNotificationsCountRequest: (state) => { + state.notificationStatus = LOADING; + }, + fetchNotificationsCountSuccess: (state, { payload }) => { + state.tabsCount = payload; + state.notificationStatus = LOADED; + state.totalUnseenCounts = payload; + }, + }, +}); + +export const { + fetchNotificationDenied, + fetchNotificationFailure, + fetchNotificationRequest, + fetchNotificationSuccess, + fetchNotificationsCountDenied, + fetchNotificationsCountFailure, + fetchNotificationsCountRequest, + fetchNotificationsCountSuccess, +} = 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..6b3b5cc --- /dev/null +++ b/src/Notifications/data/thunks.js @@ -0,0 +1,36 @@ +import { + fetchNotificationSuccess, + fetchNotificationRequest, + fetchNotificationFailure, + fetchNotificationsCountFailure, + fetchNotificationsCountRequest, + fetchNotificationsCountSuccess, +} from './slice'; +import { + getNotifications, + getNotificationCounts, +} from './api'; + +export const fetchNotificationList = ({ notificationType }) => ( + async (dispatch) => { + try { + dispatch(fetchNotificationRequest({ notificationType })); + const data = await getNotifications(notificationType); + dispatch(fetchNotificationSuccess(data)); + } catch (errors) { + dispatch(fetchNotificationFailure({ notificationType })); + } + } +); + +export const fetchNotificationsCountsList = () => ( + async (dispatch) => { + try { + dispatch(fetchNotificationsCountRequest()); + const data = await getNotificationCounts(); + dispatch(fetchNotificationsCountSuccess(data)); + } catch (errors) { + dispatch(fetchNotificationsCountFailure()); + } + } +); diff --git a/src/Notifications/icons/CheckCircleFilled.jsx b/src/Notifications/icons/CheckCircleFilled.jsx new file mode 100644 index 0000000..bd51c42 --- /dev/null +++ b/src/Notifications/icons/CheckCircleFilled.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const CheckCircleFilled = () => ( + + + + +); + +export default CheckCircleFilled; diff --git a/src/Notifications/icons/EditOutline.jsx b/src/Notifications/icons/EditOutline.jsx new file mode 100644 index 0000000..4b158a9 --- /dev/null +++ b/src/Notifications/icons/EditOutline.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const EditOutline = () => ( + + + + +); + +export default EditOutline; diff --git a/src/Notifications/icons/QuestionAnswerOutline.jsx b/src/Notifications/icons/QuestionAnswerOutline.jsx new file mode 100644 index 0000000..0137d49 --- /dev/null +++ b/src/Notifications/icons/QuestionAnswerOutline.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const QuestionAnswerOutline = () => ( + + + + +); + +export default QuestionAnswerOutline; diff --git a/src/Notifications/icons/Report.jsx b/src/Notifications/icons/Report.jsx new file mode 100644 index 0000000..87123c6 --- /dev/null +++ b/src/Notifications/icons/Report.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const Report = () => ( + + + + +); + +export default Report; diff --git a/src/Notifications/icons/ThumbsUpOutline.jsx b/src/Notifications/icons/ThumbsUpOutline.jsx new file mode 100644 index 0000000..a38b685 --- /dev/null +++ b/src/Notifications/icons/ThumbsUpOutline.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const ThumbsUpOutline = () => ( + + + + + +); + +export default ThumbsUpOutline; diff --git a/src/Notifications/icons/Verified.jsx b/src/Notifications/icons/Verified.jsx new file mode 100644 index 0000000..65cda1b --- /dev/null +++ b/src/Notifications/icons/Verified.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const Verified = () => ( + + + + +); + +export default Verified; diff --git a/src/Notifications/icons/index.js b/src/Notifications/icons/index.js index c27b06d..8cde1f2 100644 --- a/src/Notifications/icons/index.js +++ b/src/Notifications/icons/index.js @@ -1,2 +1,8 @@ export { default as PostOutline } from './PostOutline'; export { default as HelpOutline } from './HelpOutline'; +export { default as QuestionAnswerOutline } from './QuestionAnswerOutline'; +export { default as CheckCircleFilled } from './CheckCircleFilled'; +export { default as Verified } from './Verified'; +export { default as Report } from './Report'; +export { default as ThumbsUpOutline } from './ThumbsUpOutline'; +export { default as EditOutline } from './EditOutline'; diff --git a/src/Notifications/messages.js b/src/Notifications/messages.js index e7a3db8..c288449 100644 --- a/src/Notifications/messages.js +++ b/src/Notifications/messages.js @@ -2,11 +2,21 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; // eslint-disable-next-line import/prefer-default-export export const messages = defineMessages({ + notificationTitle: { + id: 'notification.title', + defaultMessage: 'Notifications', + description: 'Notifications', + }, notificationTodayHeading: { id: 'notification.today.heading', defaultMessage: 'Today', description: 'Today Notifications', }, + notificationEarlierHeading: { + id: 'notification.earlier.heading', + defaultMessage: 'Earlier', + description: 'Earlier Notifications', + }, notificationMarkAsRead: { id: 'notification.mark.as.read', defaultMessage: 'Mark all as read', @@ -14,12 +24,77 @@ export const messages = defineMessages({ }, notificationPostedContent: { id: 'notification.posted.content', - defaultMessage: '{respondingUser} posted {notificationContent}', + defaultMessage: 'posted', description: 'Display notification content for post type', }, notificationHelpedContent: { id: 'notification.helped.content', - defaultMessage: '{respondingUser} asked {notificationContent}', + defaultMessage: 'asked', description: 'Display notification content for help type', }, + notificationRespondedLabel: { + id: 'notification.responded.label', + defaultMessage: 'responded to a post you’re following', + description: 'Display notification content for respond type', + }, + notificationCommentedOnLabel: { + id: 'notification.commented.on.label', + defaultMessage: 'commented on', + description: 'Display notification content for comment type', + }, + notificationResponseOnOtherPostLabel: { + id: 'notification.response.on.other.post.label', + defaultMessage: 'response on a post you’re following:', + description: 'Display notification content for comment type for other posts', + }, + notificationQuestionLabel: { + id: 'notification.question.label', + defaultMessage: 'responded to your question', + description: 'Display notification content for question type', + }, + notificationResponseOnYourPostLabel: { + id: 'notification.response.on.your.post.label', + defaultMessage: 'response to your post', + description: 'Display notification content for comment type for your post', + }, + notificationCommentedOnYourPostLabel: { + id: 'notification.commented.on.your.post.label', + defaultMessage: 'commented on your response in', + description: 'Display notification content for comment type on your response', + }, + notificationAnswerLabel: { + id: 'notification.answer.label', + defaultMessage: 'response has been marked as answer in your post', + description: 'Display notification content for answer type', + }, + notificationEndorsedLabel: { + id: 'notification.endorsed.label', + defaultMessage: 'Your response has been endorsed in', + description: 'Display notification content for endorsed type', + }, + notificationReportedLabel: { + id: 'notification.reported.label', + defaultMessage: 'post has been reported', + description: 'Display notification content for reported type', + }, + notificationPostLikedLabel: { + id: 'notification.post.liked.label', + defaultMessage: 'liked your post', + description: 'Display notification content for post liked type', + }, + notificationCommentLikedLabel: { + id: 'notification.comment.liked.label', + defaultMessage: 'liked your response in', + description: 'Display notification content for response liked type', + }, + notificationEditedLabel: { + id: 'notification.edited.label', + defaultMessage: 'edited your post', + description: 'Display notification content for edited type', + }, + fullStop: { + id: 'notification.fullStop', + defaultMessage: '•', + description: 'Fullstop shown to users to indicate who edited a post.', + }, }); diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..fd66983 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,37 @@ +export const IDLE_STATUS = 'idle'; +export const LOADING_STATUS = 'loading'; +export const SUCCESS_STATUS = 'success'; +export const FAILURE_STATUS = 'failure'; + +export const notificationTabs = { + REMINDERS: 'reminders', + DISCUSSIONS: 'discussions', + GRADES: 'grades', + AUTHORING: 'authoring', +}; + +export const notificationTabsLabel = { + [notificationTabs.REMINDERS]: 'Reminders', + [notificationTabs.DISCUSSIONS]: 'Discussions', + [notificationTabs.GRADES]: 'Grades', + [notificationTabs.AUTHORING]: 'Authoring', +}; + +export const notificationTabsOptions = [ + { + key: notificationTabs.REMINDERS, + title: notificationTabsLabel[notificationTabs.REMINDERS], + }, + { + key: notificationTabs.DISCUSSIONS, + title: notificationTabsLabel[notificationTabs.DISCUSSIONS], + }, + { + key: notificationTabs.GRADES, + title: notificationTabsLabel[notificationTabs.GRADES], + }, + { + key: notificationTabs.AUTHORING, + title: notificationTabsLabel[notificationTabs.AUTHORING], + }, +]; diff --git a/src/index.scss b/src/index.scss index ae08e2c..2979af8 100644 --- a/src/index.scss +++ b/src/index.scss @@ -118,83 +118,7 @@ $white: #fff; border-radius: $rounded-pill; } } - -.popover .arrow{ - display: none !important; - } -.notification-title{ - line-height: 24px; - font-weight: 700; - font-size: 18px; -} -.setting-icon-container{ - display: flex; - flex-direction: row; - justify-content: flex-end; - align-items: flex-end; - width: 100%; - position: absolute; - margin-left: -40px; - margin-top: -7px; - span{ - height:20px; - width: 20px; - } -} - -.notification-content{ - padding-left: 0px; - width: 549px; - } -.notification-tabs{ - height: 38px; - width:549px; - padding-left: 12px; - button{ - font-size: 14px; - } - .dropdown-toggle{ - height: 36px; - padding-top: 0px !important; - padding-left: 12px !important; - padding-right: 26px !important; - - div{ - margin-top: 4px; - height: 20px; - width: 20px; - } - } - .dropdown{ - height: 36px; - } - .notification-tab, .dropdown-item{ - display: flex; - flex-direction: row; - align-items: center; - padding: 0px 12px 12px !important; - height: 36px; - font-size:14px; - font-weight: 500; - line-height: 24px; - -} -.expandable{ - height: 20px; - width: 20px; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - padding: 6px 7px; - gap: 8px; - position: relative; - margin-left: 4px; - } -} - .bell-container{ - button{ position: relative; background: transparent; @@ -213,17 +137,15 @@ $white: #fff; width: 36px; height: 36px; border-radius: 1e+16px; - color: black !important; &:hover{ - background: #EAE6E5; + background: #EAE6E5; } .bell-icon{ - margin-left: -4px !important; + margin-left: -3px !important; &:focus{ box-shadow: none !important } } - } .badge{ z-index: 1; @@ -244,3 +166,111 @@ $white: #fff; } } } + +.notification-tray-container{ + width: 549px; + height: 100vh; + margin-top: 9px !important; + padding: 32px 0px 24px; + max-width: 549px; + overflow: scroll; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), 0px 2px 8px rgba(0, 0, 0, 0.15); + border-radius: 0px 0px 4px 4px; + .notification-title{ + line-height: 24px; + font-weight: 700; + font-size: 18px; + } + .setting-icon-container{ + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: flex-end; + width: 100%; + position: absolute; + margin-left: -40px; + margin-top: -7px; + span{ + height:20px; + width: 20px; + } + } + .notification-section{ + padding: 10px 24px 10px 24px; + } + .notification-item{ + padding: 10px 24px 10px 24px + } + .icon-container{ + padding: 12px 12px 12px 0px + } + .notification-content{ + padding: 0px; + .notification-tabs{ + height: 38px; + width:501px; + margin-left: 12px; + button{ + font-size: 14px; + } + .dropdown-toggle::after{ + display: none; + } + .dropdown-toggle{ + height: 36px; + padding-top: 0px !important; + padding-left: 12px !important; + div{ + min-height: 6px; + min-width: 6px; + } + } + .notification-tab, .dropdown-item{ + display: flex; + flex-direction: row; + align-items: center; + padding: 0px 12px 12px !important; + height: 36px; + font-size:14px; + font-weight: 500; + line-height: 24px; + padding-bottom: 18px; + } + .expandable{ + height: 20px; + width: 20px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 6px 7px; + gap: 8px; + position: relative; + margin-left: 4px; + } + } + .notification-item-content{ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-height: 24px; + width: 417px; + } + .unread{ + max-height: 48px; + width: 24px; + div{ + background: #D23228; + border-radius: 100px; + height: 10px; + width: 10px; + } + } + .course-container{ + line-height: 20px; + font-size: 12px; + } + } +} 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; diff --git a/src/time-locale.js b/src/time-locale.js new file mode 100644 index 0000000..19ae993 --- /dev/null +++ b/src/time-locale.js @@ -0,0 +1,19 @@ +// eslint-disable-next-line no-unused-vars +export default function timeLocale(number, index, totalSec) { + 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]; +} From e76f5b693741dcca31e77f1a3655d83a6cc6842d Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Mon, 22 May 2023 13:02:57 +0500 Subject: [PATCH 03/24] feat: added add more notification button functionality --- package-lock.json | 5 ++-- package.json | 5 ++-- src/Notifications/NotificationSections.jsx | 30 ++++++++++++++++++---- src/Notifications/NotificationTabs.jsx | 18 +++++++------ src/Notifications/data/api.js | 27 ++++++++++++------- src/Notifications/data/thunks.js | 4 +-- src/Notifications/messages.js | 7 ++++- src/index.scss | 6 ++--- 8 files changed, 70 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index adc18e2..6755cc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "^1.9.5", "babel-polyfill": "6.26.0", + "lodash": "^4.17.21", + "react-redux": "7.2.9", "react-responsive": "8.2.0", "react-transition-group": "4.4.5", "timeago.js": "^4.0.2" @@ -18854,8 +18856,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", diff --git a/package.json b/package.json index 6ade22f..95cbd29 100644 --- a/package.json +++ b/package.json @@ -64,10 +64,11 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@reduxjs/toolkit": "^1.9.5", "babel-polyfill": "6.26.0", + "lodash": "^4.17.21", + "react-redux": "7.2.9", "react-responsive": "8.2.0", "react-transition-group": "4.4.5", - "timeago.js": "^4.0.2", - "react-redux": "7.2.9" + "timeago.js": "^4.0.2" }, "peerDependencies": { "@edx/frontend-platform": "^4.0.0", diff --git a/src/Notifications/NotificationSections.jsx b/src/Notifications/NotificationSections.jsx index 18b79ff..e40a64b 100644 --- a/src/Notifications/NotificationSections.jsx +++ b/src/Notifications/NotificationSections.jsx @@ -1,41 +1,61 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useSelector } from 'react-redux'; +import { Button } from '@edx/paragon'; +import PropTypes from 'prop-types'; import { messages } from './messages'; import NotificationRowItem from './NotificationRowItem'; import { getNotifications } from './data/selectors'; -const NotificationSections = () => { +const NotificationSections = ({ handleLoadMoreNotification, loadMoreCount }) => { const intl = useIntl(); const notifications = useSelector(getNotifications()); + const { TODAY, EARLIER, totalCount } = notifications || {}; return ( notifications && (
- {notifications && notifications.TODAY && intl.formatMessage(messages.notificationTodayHeading)} + {TODAY && TODAY.length > 0 && intl.formatMessage(messages.notificationTodayHeading)} + {totalCount > 0 && ( {intl.formatMessage(messages.notificationMarkAsRead)} + )}
- {notifications && notifications.TODAY && notifications?.TODAY.map( + {TODAY && TODAY.map( (notification) => , )}
- {notifications && notifications.EARLIER && intl.formatMessage(messages.notificationEarlierHeading)} + {EARLIER && EARLIER.length > 0 + && intl.formatMessage(messages.notificationEarlierHeading)}
- {notifications && notifications.EARLIER && notifications?.EARLIER.map( + {EARLIER && EARLIER.map( (notification) => , )} + {loadMoreCount < totalCount && ( + + )}
) ); }; +NotificationSections.propTypes = { + handleLoadMoreNotification: PropTypes.func.isRequired, + loadMoreCount: PropTypes.number.isRequired, +}; + export default React.memo(NotificationSections); diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 0669c70..b8f9d6d 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -11,20 +11,22 @@ import { notificationTabsOptions } from '../constants'; const NotificationTabs = () => { const notificationUnseenCounts = useSelector(getNotificationTotalUnseenCounts()); const [activeTab, setActiveTab] = useState(notificationTabsOptions[0].key); + const [loadMoreCount, setLoadMoreCount] = useState(10); + const dispatch = useDispatch(); useEffect(() => { - dispatch(fetchNotificationList({ notificationType: activeTab || 'reminders' })); - }, [dispatch, activeTab]); - - useEffect(() => { - setActiveTab(activeTab || 'reminders'); - }, [activeTab]); + dispatch(fetchNotificationList({ notificationType: activeTab || 'reminders', notificationCount: loadMoreCount })); + }, [dispatch, activeTab, loadMoreCount]); const handleActiveTab = useCallback((tab) => { setActiveTab(tab); }, []); + const handleLoadMoreNotification = useCallback((count) => { + setLoadMoreCount(count); + }, []); + const tabArray = useMemo(() => notificationTabsOptions.map((option) => ( { notification={notificationUnseenCounts[option.title]} tabClassName="notification-tab" > - + - )), [notificationUnseenCounts]); + )), [notificationUnseenCounts, handleLoadMoreNotification, loadMoreCount]); return ( activeTab && ( diff --git a/src/Notifications/data/api.js b/src/Notifications/data/api.js index 67a5198..0de5a60 100644 --- a/src/Notifications/data/api.js +++ b/src/Notifications/data/api.js @@ -1,15 +1,8 @@ -/* eslint-disable import/prefer-default-export */ -// import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getConfig } from '@edx/frontend-platform'; export const getApiBaseUrl = () => getConfig().LMS_BASE_URL; -export async function getNotifications(notificationType) { - // const url = `${getApiBaseUrl()}/api/discussion/v1/notifications/`; - - // const { data } = await getAuthenticatedHttpClient() - // .get(url); - +export async function getNotifications(notificationType, notificationCount) { const data = { discussions: { TODAY: [ @@ -185,7 +178,23 @@ export async function getNotifications(notificationType) { ], }, }; - return data[notificationType]; + const notifications = data[notificationType]; + const { TODAY = [], EARLIER = [] } = notifications || []; + let todayNotifications = TODAY; + let earlierNotifications = []; + let totalCount = 0; + + if (TODAY && EARLIER) { + if (TODAY.length > notificationCount) { + todayNotifications = TODAY.slice(0, notificationCount); + } else { + todayNotifications = TODAY; + earlierNotifications = EARLIER.slice(0, notificationCount - TODAY.length); + } + totalCount = TODAY.length + EARLIER.length; + } + + return { TODAY: todayNotifications, EARLIER: earlierNotifications, totalCount }; } export async function getNotificationCounts() { diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index 6b3b5cc..3e6f55b 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -11,11 +11,11 @@ import { getNotificationCounts, } from './api'; -export const fetchNotificationList = ({ notificationType }) => ( +export const fetchNotificationList = ({ notificationType, notificationCount }) => ( async (dispatch) => { try { dispatch(fetchNotificationRequest({ notificationType })); - const data = await getNotifications(notificationType); + const data = await getNotifications(notificationType, notificationCount); dispatch(fetchNotificationSuccess(data)); } catch (errors) { dispatch(fetchNotificationFailure({ notificationType })); diff --git a/src/Notifications/messages.js b/src/Notifications/messages.js index c288449..62de292 100644 --- a/src/Notifications/messages.js +++ b/src/Notifications/messages.js @@ -9,7 +9,7 @@ export const messages = defineMessages({ }, notificationTodayHeading: { id: 'notification.today.heading', - defaultMessage: 'Today', + defaultMessage: 'Last 24 hours', description: 'Today Notifications', }, notificationEarlierHeading: { @@ -97,4 +97,9 @@ export const messages = defineMessages({ 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', + }, }); diff --git a/src/index.scss b/src/index.scss index 2979af8..c691cdb 100644 --- a/src/index.scss +++ b/src/index.scss @@ -141,7 +141,8 @@ $white: #fff; background: #EAE6E5; } .bell-icon{ - margin-left: -3px !important; + margin-left: -4px !important; + margin-top: -1px; &:focus{ box-shadow: none !important } @@ -155,7 +156,7 @@ $white: #fff; width: 23px; height: 16px; margin-top: 3px; - margin-left: 20px; + margin-left: 18px; .count{ font-size: 9px; line-height: 20px; @@ -166,7 +167,6 @@ $white: #fff; } } } - .notification-tray-container{ width: 549px; height: 100vh; From 4ce7311809abb708f2ac9d431189e5036871464d Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Mon, 22 May 2023 15:17:04 +0500 Subject: [PATCH 04/24] refactor: removed unused states --- src/constants.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/constants.js b/src/constants.js index fd66983..6a176ef 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,8 +1,3 @@ -export const IDLE_STATUS = 'idle'; -export const LOADING_STATUS = 'loading'; -export const SUCCESS_STATUS = 'success'; -export const FAILURE_STATUS = 'failure'; - export const notificationTabs = { REMINDERS: 'reminders', DISCUSSIONS: 'discussions', From 7034d10536b365b7f4f86280ad156c52cdcbc5be Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Mon, 22 May 2023 18:14:09 +0500 Subject: [PATCH 05/24] refactor: fixed snapshot and store structure in header test file --- src/Header.test.jsx | 26 ++++++++++++---- src/Notifications/NotificationRowItem.jsx | 8 ++--- src/Notifications/NotificationTabs.jsx | 4 +-- src/__snapshots__/Header.test.jsx.snap | 37 +++++++++++++++++++++++ 4 files changed, 63 insertions(+), 12 deletions(-) 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 index b8c97d3..b6ebc7b 100644 --- a/src/Notifications/NotificationRowItem.jsx +++ b/src/Notifications/NotificationRowItem.jsx @@ -16,7 +16,7 @@ const NotificationRowItem = ({ notification }) => { timeago.register('time-locale', timeLocale); const { authenticatedUser } = useContext(AppContext); - const handleIconByType = (type) => { + const getIconByType = (type) => { switch (type) { case 'post': return PostOutline; case 'help': return HelpOutline; @@ -34,7 +34,7 @@ const NotificationRowItem = ({ notification }) => { }; const getContentMessageByType = useCallback(() => { - const typeMap = { + const contentMessage = { post: messages.notificationPostedContent, help: messages.notificationHelpedContent, respond: authenticatedUser.username !== notification.author @@ -49,14 +49,14 @@ const NotificationRowItem = ({ notification }) => { commentLiked: messages.notificationCommentLikedLabel, edited: messages.notificationEditedLabel, }; - return typeMap[notification.type] ? intl.formatMessage(typeMap[notification.type]) : null; + return contentMessage[notification.type] ? intl.formatMessage(contentMessage[notification.type]) : null; }, [authenticatedUser, notification, intl]); return (
diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index b8f9d6d..6898d3a 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -16,7 +16,7 @@ const NotificationTabs = () => { const dispatch = useDispatch(); useEffect(() => { - dispatch(fetchNotificationList({ notificationType: activeTab || 'reminders', notificationCount: loadMoreCount })); + dispatch(fetchNotificationList({ notificationType: activeTab, notificationCount: loadMoreCount })); }, [dispatch, activeTab, loadMoreCount]); const handleActiveTab = useCallback((tab) => { @@ -42,7 +42,7 @@ const NotificationTabs = () => { activeTab && ( diff --git a/src/__snapshots__/Header.test.jsx.snap b/src/__snapshots__/Header.test.jsx.snap index f83161b..4c36abd 100644 --- a/src/__snapshots__/Header.test.jsx.snap +++ b/src/__snapshots__/Header.test.jsx.snap @@ -237,6 +237,43 @@ exports[`
renders correctly for authenticated desktop 1`] = ` aria-label="Secondary" className="nav secondary-menu-container align-items-center ml-auto" > +
+
+ +
+
Date: Tue, 23 May 2023 12:16:22 +0500 Subject: [PATCH 06/24] feat: notification tray closes when clicked outside --- src/Notifications/Notifications.jsx | 52 ++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/Notifications.jsx index f246265..0a70add 100644 --- a/src/Notifications/Notifications.jsx +++ b/src/Notifications/Notifications.jsx @@ -1,4 +1,6 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { + useState, useCallback, useEffect, useRef, +} from 'react'; import { NotificationsNone, Settings } from '@edx/paragon/icons'; import { Badge, Form, Icon, IconButton, OverlayTrigger, Popover, @@ -12,11 +14,12 @@ import { messages } from './messages'; const Notifications = () => { const [showNotificationTray, setShowNotificationTray] = useState(false); - const notificationCounts = useSelector(getNotificationTotalUnseenCounts()); const intl = useIntl(); - + const popoverRef = useRef(null); + const buttonRef = useRef(null); const dispatch = useDispatch(); const notificationStatus = useSelector(getNotificationStatus()); + const notificationCounts = useSelector(getNotificationTotalUnseenCounts()); useEffect(() => { if (notificationStatus === 'idle') { @@ -28,6 +31,23 @@ const Notifications = () => { setShowNotificationTray(value); }, []); + useEffect(() => { + const handleClickOutside = (event) => { + if ( + popoverRef.current + && buttonRef.current + && !popoverRef.current.contains(event.target) + && !buttonRef.current.contains(event.target) + ) { + setShowNotificationTray(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + return (
{ id="popover-positioned-bottom" className="notification-tray-container" > - -

- {intl.formatMessage(messages.notificationTitle)} -

-
- -
-
- - - +
+ +

+ {intl.formatMessage(messages.notificationTitle)} +

+
+ +
+
+ + + +
)} > @@ -60,7 +82,7 @@ const Notifications = () => { {notificationCounts?.Total} )} -
+
{ handleNotificationTray(!showNotificationTray); }} src={NotificationsNone} From 061746da9fa00e0c55aab0fb30c1200d28167663 Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Wed, 24 May 2023 15:14:41 +0500 Subject: [PATCH 07/24] refactor: used paragon icons and updated css --- src/Notifications/NotificationRowItem.jsx | 50 +++++++------- src/Notifications/NotificationSections.jsx | 4 +- src/Notifications/NotificationTabs.jsx | 7 +- src/Notifications/Notifications.jsx | 14 ++-- src/{ => Notifications/data}/constants.js | 0 src/Notifications/icons/CheckCircleFilled.jsx | 19 ------ src/Notifications/icons/EditOutline.jsx | 19 ------ src/Notifications/icons/HelpOutline.jsx | 26 ------- src/Notifications/icons/PostOutline.jsx | 18 ----- .../icons/QuestionAnswerOutline.jsx | 19 ------ src/Notifications/icons/Report.jsx | 19 ------ src/Notifications/icons/ThumbsUpOutline.jsx | 22 ------ src/Notifications/icons/Verified.jsx | 19 ------ src/Notifications/icons/index.js | 8 --- src/__snapshots__/Header.test.jsx.snap | 4 +- src/{ => common}/time-locale.js | 0 src/index.scss | 67 ++++--------------- 17 files changed, 53 insertions(+), 262 deletions(-) rename src/{ => Notifications/data}/constants.js (100%) delete mode 100644 src/Notifications/icons/CheckCircleFilled.jsx delete mode 100644 src/Notifications/icons/EditOutline.jsx delete mode 100644 src/Notifications/icons/HelpOutline.jsx delete mode 100644 src/Notifications/icons/PostOutline.jsx delete mode 100644 src/Notifications/icons/QuestionAnswerOutline.jsx delete mode 100644 src/Notifications/icons/Report.jsx delete mode 100644 src/Notifications/icons/ThumbsUpOutline.jsx delete mode 100644 src/Notifications/icons/Verified.jsx delete mode 100644 src/Notifications/icons/index.js rename src/{ => common}/time-locale.js (100%) diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx index b6ebc7b..c21793a 100644 --- a/src/Notifications/NotificationRowItem.jsx +++ b/src/Notifications/NotificationRowItem.jsx @@ -5,11 +5,11 @@ import { Icon } from '@edx/paragon'; import * as timeago from 'timeago.js'; import PropTypes from 'prop-types'; import { AppContext } from '@edx/frontend-platform/react'; -import { messages } from './messages'; import { - PostOutline, HelpOutline, QuestionAnswerOutline, CheckCircleFilled, Verified, Report, ThumbsUpOutline, EditOutline, -} from './icons'; -import timeLocale from '../time-locale'; + CheckCircle, HelpOutline, QuestionAnswerOutline, Verified, Report, EditOutline, ThumbUpOutline, PostOutline, +} from '@edx/paragon/icons'; +import { messages } from './messages'; +import timeLocale from '../common/time-locale'; const NotificationRowItem = ({ notification }) => { const intl = useIntl(); @@ -17,20 +17,21 @@ const NotificationRowItem = ({ notification }) => { const { authenticatedUser } = useContext(AppContext); const getIconByType = (type) => { - switch (type) { - case 'post': return PostOutline; - case 'help': return HelpOutline; - case 'respond': return QuestionAnswerOutline; - case 'comment': return QuestionAnswerOutline; - case 'question': return QuestionAnswerOutline; - case 'answer': return CheckCircleFilled; - case 'endorsed': return Verified; - case 'reported': return Report; - case 'postLiked': return ThumbsUpOutline; - case 'commentLiked': return ThumbsUpOutline; - case 'edited': return EditOutline; - default: return null; - } + 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] || null; }; const getContentMessageByType = useCallback(() => { @@ -52,17 +53,19 @@ const NotificationRowItem = ({ notification }) => { return contentMessage[notification.type] ? intl.formatMessage(contentMessage[notification.type]) : null; }, [authenticatedUser, notification, intl]); + const iconComponent = getIconByType(notification.type); return ( -
-
+
+
- + {notification?.respondingUser} {' '} {getContentMessageByType()} {notification?.targetUser && ( @@ -80,8 +83,7 @@ const NotificationRowItem = ({ notification }) => {
- {notification.status === 'unread' - &&
} + {notification.status === 'unread' &&
}
diff --git a/src/Notifications/NotificationSections.jsx b/src/Notifications/NotificationSections.jsx index e40a64b..d71329c 100644 --- a/src/Notifications/NotificationSections.jsx +++ b/src/Notifications/NotificationSections.jsx @@ -15,7 +15,7 @@ const NotificationSections = ({ handleLoadMoreNotification, loadMoreCount }) => return ( notifications && (
-
+
{TODAY && TODAY.length > 0 && intl.formatMessage(messages.notificationTodayHeading)} @@ -29,7 +29,7 @@ const NotificationSections = ({ handleLoadMoreNotification, loadMoreCount }) => {TODAY && TODAY.map( (notification) => , )} -
+
{EARLIER && EARLIER.length > 0 && intl.formatMessage(messages.notificationEarlierHeading)} diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 6898d3a..0828fab 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -6,7 +6,7 @@ import { useSelector, useDispatch } from 'react-redux'; import NotificationSections from './NotificationSections'; import { getNotificationTotalUnseenCounts } from './data/selectors'; import { fetchNotificationList } from './data/thunks'; -import { notificationTabsOptions } from '../constants'; +import { notificationTabsOptions } from './data/constants'; const NotificationTabs = () => { const notificationUnseenCounts = useSelector(getNotificationTotalUnseenCounts()); @@ -32,7 +32,7 @@ const NotificationTabs = () => { eventKey={option.key} title={option.title} notification={notificationUnseenCounts[option.title]} - tabClassName="notification-tab" + tabClassName="notification-tab d-flex flex-row align-items-center" > @@ -42,8 +42,7 @@ const NotificationTabs = () => { activeTab && ( {tabArray} diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/Notifications.jsx index 0a70add..60d0f96 100644 --- a/src/Notifications/Notifications.jsx +++ b/src/Notifications/Notifications.jsx @@ -58,18 +58,20 @@ const Notifications = () => { overlay={(

{intl.formatMessage(messages.notificationTitle)}

-
+
- +
@@ -78,16 +80,16 @@ const Notifications = () => { > <> {notificationCounts?.Total > 0 && ( - + {notificationCounts?.Total} )} -
+
{ handleNotificationTray(!showNotificationTray); }} src={NotificationsNone} iconAs={Icon} - className="d-inline-block align-bottom ml-1 bell-icon" + className="d-inline-block align-bottom ml-1 ml-n1 shadow-none bg-transparent text-primary-500" />
diff --git a/src/constants.js b/src/Notifications/data/constants.js similarity index 100% rename from src/constants.js rename to src/Notifications/data/constants.js diff --git a/src/Notifications/icons/CheckCircleFilled.jsx b/src/Notifications/icons/CheckCircleFilled.jsx deleted file mode 100644 index bd51c42..0000000 --- a/src/Notifications/icons/CheckCircleFilled.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -const CheckCircleFilled = () => ( - - - - -); - -export default CheckCircleFilled; diff --git a/src/Notifications/icons/EditOutline.jsx b/src/Notifications/icons/EditOutline.jsx deleted file mode 100644 index 4b158a9..0000000 --- a/src/Notifications/icons/EditOutline.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -const EditOutline = () => ( - - - - -); - -export default EditOutline; diff --git a/src/Notifications/icons/HelpOutline.jsx b/src/Notifications/icons/HelpOutline.jsx deleted file mode 100644 index 53cdd48..0000000 --- a/src/Notifications/icons/HelpOutline.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; - -const HelpOutline = () => ( - - - - - - - - - - - -); - -export default HelpOutline; diff --git a/src/Notifications/icons/PostOutline.jsx b/src/Notifications/icons/PostOutline.jsx deleted file mode 100644 index a7bb4d4..0000000 --- a/src/Notifications/icons/PostOutline.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; - -const PostOutline = () => ( - - - -); - -export default PostOutline; diff --git a/src/Notifications/icons/QuestionAnswerOutline.jsx b/src/Notifications/icons/QuestionAnswerOutline.jsx deleted file mode 100644 index 0137d49..0000000 --- a/src/Notifications/icons/QuestionAnswerOutline.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -const QuestionAnswerOutline = () => ( - - - - -); - -export default QuestionAnswerOutline; diff --git a/src/Notifications/icons/Report.jsx b/src/Notifications/icons/Report.jsx deleted file mode 100644 index 87123c6..0000000 --- a/src/Notifications/icons/Report.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -const Report = () => ( - - - - -); - -export default Report; diff --git a/src/Notifications/icons/ThumbsUpOutline.jsx b/src/Notifications/icons/ThumbsUpOutline.jsx deleted file mode 100644 index a38b685..0000000 --- a/src/Notifications/icons/ThumbsUpOutline.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -const ThumbsUpOutline = () => ( - - - - - -); - -export default ThumbsUpOutline; diff --git a/src/Notifications/icons/Verified.jsx b/src/Notifications/icons/Verified.jsx deleted file mode 100644 index 65cda1b..0000000 --- a/src/Notifications/icons/Verified.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -const Verified = () => ( - - - - -); - -export default Verified; diff --git a/src/Notifications/icons/index.js b/src/Notifications/icons/index.js deleted file mode 100644 index 8cde1f2..0000000 --- a/src/Notifications/icons/index.js +++ /dev/null @@ -1,8 +0,0 @@ -export { default as PostOutline } from './PostOutline'; -export { default as HelpOutline } from './HelpOutline'; -export { default as QuestionAnswerOutline } from './QuestionAnswerOutline'; -export { default as CheckCircleFilled } from './CheckCircleFilled'; -export { default as Verified } from './Verified'; -export { default as Report } from './Report'; -export { default as ThumbsUpOutline } from './ThumbsUpOutline'; -export { default as EditOutline } from './EditOutline'; diff --git a/src/__snapshots__/Header.test.jsx.snap b/src/__snapshots__/Header.test.jsx.snap index 4c36abd..e19fabe 100644 --- a/src/__snapshots__/Header.test.jsx.snap +++ b/src/__snapshots__/Header.test.jsx.snap @@ -241,10 +241,10 @@ exports[`
renders correctly for authenticated desktop 1`] = ` className="d-flex mx-4 my-3 bell-container" >
+ )} -
) ); diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 8fd9cea..4a2748a 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -32,8 +32,8 @@ const NotificationTabs = () => { {option.key === selectedNotificationType && } @@ -47,15 +47,14 @@ const NotificationTabs = () => { } return ( - activeTab && ( {tabArray} - ) ); }; diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/Notifications.jsx index 76f1072..c7381ab 100644 --- a/src/Notifications/Notifications.jsx +++ b/src/Notifications/Notifications.jsx @@ -3,14 +3,16 @@ import React, { } from 'react'; import { NotificationsNone, Settings } from '@edx/paragon/icons'; import { - Badge, Form, Icon, IconButton, OverlayTrigger, Popover, + Badge, Icon, IconButton, OverlayTrigger, Popover, } from '@edx/paragon'; import { useSelector, useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; import NotificationTabs from './NotificationTabs'; import { getNotificationTotalUnseenCounts, getNotificationStatus } from './data/selectors'; import { fetchNotificationsCountsList } from './data/thunks'; import { messages } from './messages'; +import { useIsOnDesktop, useIsOnXLDesktop } from './data/hook'; const Notifications = () => { const [showNotificationTray, setShowNotificationTray] = useState(false); @@ -20,6 +22,8 @@ const Notifications = () => { const dispatch = useDispatch(); const notificationStatus = useSelector(getNotificationStatus()); const notificationCounts = useSelector(getNotificationTotalUnseenCounts()); + const isOnDesktop = useIsOnDesktop(); + const isOnXLDesktop = useIsOnXLDesktop(); useEffect(() => { if (notificationStatus === 'idle') { @@ -57,14 +61,18 @@ const Notifications = () => { overlay={(

{intl.formatMessage(messages.notificationTitle)} @@ -78,38 +86,26 @@ const Notifications = () => { )} > - <> - {/* {notificationCounts?.Total > 0 && ( - - {notificationCounts?.Total} - - )} */} -
- { handleNotificationTray(!showNotificationTray); }} - src={NotificationsNone} - iconAs={Icon} - variant="light" - iconClassNames="text-primary-500" - className="ml-4 mr-1 my-3" - style={{ width: '36px', height: '36px' }} - /> - - { notificationCounts?.Total > 0 && notificationCounts?.Total} - -
- +
+ { handleNotificationTray(!showNotificationTray); }} + src={NotificationsNone} + iconAs={Icon} + variant="light" + iconClassNames="text-primary-500" + className="ml-4 mr-1 my-3" + style={{ width: '36px', height: '36px' }} + /> + + { notificationCounts?.count > 0 && notificationCounts?.count} + +
); }; diff --git a/src/Notifications/data/api.js b/src/Notifications/data/api.js index 6f078c1..ff3e680 100644 --- a/src/Notifications/data/api.js +++ b/src/Notifications/data/api.js @@ -1,6 +1,4 @@ -import { getConfig } from '@edx/frontend-platform'; - -export const getApiBaseUrl = () => getConfig().LMS_BASE_URL; +import { camelCaseObject } from '@edx/frontend-platform'; const parseNotificationList = (notificationList) => { const currentTime = Date.now(); @@ -17,190 +15,185 @@ export async function getNotifications(notificationType, notificationCount) { const notificationData = [ { type: 'post', - respondingUser: 'SCM_Lead', - notificationContent: 'Hello and welcome to SC0x!', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1684996319038', - }, - { - type: 'post', - respondingUser: 'SCM_Lead', - notificationContent: 'Hello and welcome to SC0x!', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1684996319038', - }, - { - type: 'post', - respondingUser: 'SCM_Lead', - notificationContent: 'Hello and welcome to SC0x!', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1684996319038', + responding_user: 'SCM_Lead', + notification_content: 'Hello and welcome to SC0x!', + target_user: '', + course_name: 'Supply Chain Analytics', + content_content_url: '', + is_read: false, + is_seen: false, + time: '1685361282018', }, { type: 'help', - respondingUser: 'MITx_Learner', - notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1684996339844', + responding_user: 'MITx_Learner', + notification_content: 'What grade does a student need to get in order to pass the course and earn a certificate?', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, + time: '1685361282018', }, { type: 'post', - respondingUser: 'SCM_Lead', - notificationContent: 'Hello and welcome to SC0x!', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'SCM_Lead', + notification_content: 'Hello and welcome to SC0x!', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, time: '1684253634808', author: '', }, { type: 'help', - respondingUser: 'MITx_Learner', - notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'MITx_Learner', + notification_content: 'What grade does a student need to get in order to pass the course and earn a certificate?', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, time: '1684253736371', author: '', }, { type: 'respond', - respondingUser: 'MITx_Learner', - notificationContent: 'Can’t find linear regression in section 3 review', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'MITx_Learner', + notification_content: 'Can’t find linear regression in section 3 review', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, time: '1684253736371', author: '', }, { type: 'comment', - respondingUser: 'MITx_Learner', - notificationContent: 'Can’t find linear regression in section 3 review', - targetUser: 'MITx_Expert’s ', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'MITx_Learner', + notification_content: 'Can’t find linear regression in section 3 review', + target_user: 'MITx_Expert’s ', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, time: '1684253736371', author: '', }, { type: 'question', - respondingUser: 'MITx_Learner', - notificationContent: 'Examples of quadratic equations in supply chains', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'MITx_Learner', + notification_content: 'Examples of quadratic equations in supply chains', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, time: '1684253736371', author: '', }, { type: 'comment', - respondingUser: 'MITx_Learner', - notificationContent: 'What grade does a student need to get in order to pass the course and earn a certificate?', - targetUser: 'MITx_Expert’s ', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'MITx_Learner', + notification_content: 'What grade does a student need to get in order to pass the course and earn a certificate?', + target_user: 'MITx_Expert’s ', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, time: '1684253736371', + is_seen: false, author: 'testuser', }, { type: 'comment', - respondingUser: 'MITx_Learner', - notificationContent: 'Convexity of f(x)=1/x , x>1', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'MITx_Learner', + notification_content: 'Convexity of f(x)=1/x , x>1', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, time: '1684253736371', + is_seen: false, author: 'testuser', }, { type: 'answer', - respondingUser: 'SCM_Lead', - notificationContent: 'Quiz in section 3 - Please explain the F-Significance value', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: 'SCM_Lead', + notification_content: 'Quiz in section 3 - Please explain the F-Significance value', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, time: '1685096268835', + is_seen: false, author: 'testuser', }, { type: 'endorsed', - respondingUser: '', - notificationContent: 'Quiz in section 3 - Please explain the F-Significance value', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', + responding_user: '', + notification_content: 'Quiz in section 3 - Please explain the F-Significance value', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, time: '1685096268835', author: 'testuser', }, { type: 'reported', - respondingUser: 'MITx Learner’s', - notificationContent: '“Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1685096268835', + responding_user: 'MITx Learner’s', + notification_content: '“Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, + time: '1685212056931', author: '', }, { type: 'postLiked', - respondingUser: 'SCM_Lead', - notificationContent: 'Retaking the course', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1685096268835', + responding_user: 'SCM_Lead', + notification_content: 'Retaking the course', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, + time: '1685212056931', author: '', }, { type: 'commentLiked', - respondingUser: 'MITx_Expert ', - notificationContent: 'Final exam answers', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1685096268835', + responding_user: 'MITx_Expert ', + notification_content: 'Final exam answers', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, + time: '1685212056931', author: '', }, { type: 'edited', - respondingUser: 'MITx_Expert ', - notificationContent: 'Question 1', - targetUser: '', - courseName: 'Supply Chain Analytics', - URL: '', - status: 'unread', - time: '1685096268835', + responding_user: 'MITx_Expert ', + notification_content: 'Question 1', + target_user: '', + course_name: 'Supply Chain Analytics', + content_url: '', + is_read: false, + is_seen: false, + time: '1685212056931', author: '', }, ]; - const { today, earlier } = parseNotificationList(notificationData); + const { today, earlier } = parseNotificationList(camelCaseObject(notificationData)); const data = { discussions: { @@ -233,11 +226,13 @@ export async function getNotifications(notificationType, notificationCount) { export async function getNotificationCounts() { const data = { - Total: 25, - Reminders: 10, - Discussions: 5, - Grades: 4, - Authoring: 6, + count: 25, + count_by_app_name: { + reminders: 10, + discussions: 5, + grades: 4, + authoring: 6, + }, }; - return data; + return camelCaseObject(data); } diff --git a/src/Notifications/data/hook.js b/src/Notifications/data/hook.js new file mode 100644 index 0000000..9136494 --- /dev/null +++ b/src/Notifications/data/hook.js @@ -0,0 +1,10 @@ +import { breakpoints, useWindowSize } from '@edx/paragon'; + +export function useIsOnDesktop() { + const windowSize = useWindowSize(); + return windowSize.width >= breakpoints.medium.minWidth; +} +export function useIsOnXLDesktop() { + const windowSize = useWindowSize(); + return windowSize.width >= breakpoints.extraLarge.minWidth; +} diff --git a/src/__snapshots__/Header.test.jsx.snap b/src/__snapshots__/Header.test.jsx.snap index 3768354..f83161b 100644 --- a/src/__snapshots__/Header.test.jsx.snap +++ b/src/__snapshots__/Header.test.jsx.snap @@ -237,43 +237,6 @@ exports[`
renders correctly for authenticated desktop 1`] = ` aria-label="Secondary" className="nav secondary-menu-container align-items-center ml-auto" > -
-
- -
-
{ it('displays user button', () => { render(
); - expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username); + expect(screen.getByText(authenticatedUser.username)).toBeInTheDocument(); }); it('displays course data', () => { From 72e82005c0a4e4afc9630e5873e7c60543a7073a Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Wed, 31 May 2023 11:50:42 +0500 Subject: [PATCH 12/24] feat: added notification APIs --- src/Notifications/NotificationTabs.jsx | 21 ++++++++++++------- src/Notifications/data/api.js | 29 ++++++++++++++++++++------ src/Notifications/data/selectors.js | 2 +- src/Notifications/data/slice.js | 24 +++++++++++++++++---- src/Notifications/data/thunks.js | 26 +++++++++++++++++++---- src/index.scss | 3 +-- 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 4a2748a..4e9cdd2 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -4,21 +4,25 @@ import React, { import { Tabs, Tab } from '@edx/paragon'; import { useSelector, useDispatch } from 'react-redux'; import NotificationSections from './NotificationSections'; -import { getNotificationTotalUnseenCounts, getSelectedNotificationType } from './data/selectors'; -import { fetchNotificationList } from './data/thunks'; +import { getNotificationTotalUnseenCounts, getSelectedAppName } from './data/selectors'; +import { fetchNotificationList, markNotificationsAsSeen } from './data/thunks'; import { notificationTabsOptions } from './data/constants'; const NotificationTabs = () => { const notificationUnseenCounts = useSelector(getNotificationTotalUnseenCounts()); - const selectedNotificationType = useSelector(getSelectedNotificationType()); + const selectedappName = useSelector(getSelectedAppName()); const [activeTab, setActiveTab] = useState(notificationTabsOptions[0].key); const [loadMoreCount, setLoadMoreCount] = useState(10); + const [page, setPage] = useState(1); const dispatch = useDispatch(); useEffect(() => { - dispatch(fetchNotificationList({ notificationType: activeTab, notificationCount: loadMoreCount })); - }, [dispatch, activeTab, loadMoreCount]); + dispatch(fetchNotificationList({ + appName: activeTab, notificationCount: loadMoreCount, page, pageSize: 10, + })); + dispatch(markNotificationsAsSeen(activeTab)); + }, [dispatch, activeTab, loadMoreCount, page]); const handleActiveTab = useCallback((tab) => { setActiveTab(tab); @@ -26,7 +30,8 @@ const NotificationTabs = () => { const handleLoadMoreNotification = useCallback((count) => { setLoadMoreCount(count); - }, []); + setPage(page + 1); + }, [page]); const tabArray = useMemo(() => notificationTabsOptions.map((option) => ( { notification={notificationUnseenCounts.countByAppName[option.key]} tabClassName="pt-0 pb-2.5 px-2.5 d-flex flex-row align-items-center line-height-24" > - {option.key === selectedNotificationType + {option.key === selectedappName && } - )), [notificationUnseenCounts, handleLoadMoreNotification, loadMoreCount, selectedNotificationType]); + )), [notificationUnseenCounts, handleLoadMoreNotification, loadMoreCount, selectedappName]); // This code is used to replace More... text to More to match the UI const buttons = document.getElementsByClassName('dropdown-toggle'); diff --git a/src/Notifications/data/api.js b/src/Notifications/data/api.js index ff3e680..efaf7d8 100644 --- a/src/Notifications/data/api.js +++ b/src/Notifications/data/api.js @@ -1,4 +1,9 @@ -import { camelCaseObject } from '@edx/frontend-platform'; +import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export const getNotificationsCountApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/count/`; +export const getNotificationsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`; +export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-notifications-unseen/${appName}/`; const parseNotificationList = (notificationList) => { const currentTime = Date.now(); @@ -11,7 +16,12 @@ const parseNotificationList = (notificationList) => { }); return { today, earlier }; }; -export async function getNotifications(notificationType, notificationCount) { + +export async function getNotifications(appName, notificationCount, page, pageSize) { + const params = snakeCaseObject({ page, pageSize }); + + let { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params }); + const notificationData = [ { type: 'post', @@ -194,8 +204,7 @@ export async function getNotifications(notificationType, notificationCount) { ]; const { today, earlier } = parseNotificationList(camelCaseObject(notificationData)); - - const data = { + data = { discussions: { TODAY: today, EARLIER: earlier, @@ -205,7 +214,8 @@ export async function getNotifications(notificationType, notificationCount) { EARLIER: earlier, }, }; - const notifications = data[notificationType]; + + const notifications = data[appName]; const { TODAY = [], EARLIER = [] } = notifications || []; let todayNotifications = TODAY; let earlierNotifications = []; @@ -225,7 +235,8 @@ export async function getNotifications(notificationType, notificationCount) { } export async function getNotificationCounts() { - const data = { + let { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl()); + data = { count: 25, count_by_app_name: { reminders: 10, @@ -236,3 +247,9 @@ export async function getNotificationCounts() { }; return camelCaseObject(data); } + +export async function markNotificationSeen(appName) { + const { data } = await getAuthenticatedHttpClient() + .put(`${markNotificationsSeenApiUrl(appName)}`); + return camelCaseObject(data); +} diff --git a/src/Notifications/data/selectors.js b/src/Notifications/data/selectors.js index d62a52b..295dfc1 100644 --- a/src/Notifications/data/selectors.js +++ b/src/Notifications/data/selectors.js @@ -1,4 +1,4 @@ export const getNotificationStatus = () => state => state.notifications.notificationStatus; export const getNotificationTotalUnseenCounts = () => state => state.notifications.totalUnseenCounts; export const getNotifications = () => state => state.notifications.notifications; -export const getSelectedNotificationType = () => state => state.notifications.notificationType; +export const getSelectedAppName = () => state => state.notifications.appName; diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js index b302f99..ac0fa6c 100644 --- a/src/Notifications/data/slice.js +++ b/src/Notifications/data/slice.js @@ -12,19 +12,19 @@ const slice = createSlice({ notificationStatus: 'idle', notifications: {}, totalUnseenCounts: {}, - notificationType: '', + appName: '', }, reducers: { fetchNotificationDenied: (state, { payload }) => { - state.notificationType = payload.notificationType; + state.appName = payload.appName; state.notificationStatus = DENIED; }, fetchNotificationFailure: (state, { payload }) => { - state.notificationType = payload.notificationType; + state.appName = payload.appName; state.notificationStatus = FAILED; }, fetchNotificationRequest: (state, { payload }) => { - state.notificationType = payload.notificationType; + state.appName = payload.appName; state.notificationStatus = LOADING; }, fetchNotificationSuccess: (state, { payload }) => { @@ -45,6 +45,18 @@ const slice = createSlice({ state.notificationStatus = LOADED; state.totalUnseenCounts = payload; }, + markNotificationsAsSeenRequest: (state) => { + state.notificationStatus = LOADING; + }, + markNotificationsAsSeenSuccess: (state) => { + state.notificationStatus = LOADED; + }, + markNotificationsAsSeenDenied: (state) => { + state.notificationStatus = DENIED; + }, + markNotificationsAsSeenFailure: (state) => { + state.notificationStatus = FAILED; + }, }, }); @@ -57,6 +69,10 @@ export const { fetchNotificationsCountFailure, fetchNotificationsCountRequest, fetchNotificationsCountSuccess, + markNotificationsAsSeenRequest, + markNotificationsAsSeenSuccess, + markNotificationsAsSeenFailure, + markNotificationsAsSeenDenied, } = slice.actions; export const notificationsReducer = slice.reducer; diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index 3e6f55b..4c61ee4 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -5,20 +5,26 @@ import { fetchNotificationsCountFailure, fetchNotificationsCountRequest, fetchNotificationsCountSuccess, + markNotificationsAsSeenRequest, + markNotificationsAsSeenSuccess, + markNotificationsAsSeenFailure, } from './slice'; import { getNotifications, getNotificationCounts, + markNotificationSeen, } from './api'; -export const fetchNotificationList = ({ notificationType, notificationCount }) => ( +export const fetchNotificationList = ({ + appName, notificationCount, page, pageSize, +}) => ( async (dispatch) => { try { - dispatch(fetchNotificationRequest({ notificationType })); - const data = await getNotifications(notificationType, notificationCount); + dispatch(fetchNotificationRequest({ appName })); + const data = await getNotifications(appName, notificationCount, page, pageSize); dispatch(fetchNotificationSuccess(data)); } catch (errors) { - dispatch(fetchNotificationFailure({ notificationType })); + dispatch(fetchNotificationFailure({ appName })); } } ); @@ -34,3 +40,15 @@ export const fetchNotificationsCountsList = () => ( } } ); + +export const markNotificationsAsSeen = (appName) => ( + async (dispatch) => { + try { + dispatch(markNotificationsAsSeenRequest({ appName })); + const data = await markNotificationSeen(appName); + dispatch(markNotificationsAsSeenSuccess(data)); + } catch (errors) { + dispatch(markNotificationsAsSeenFailure({ appName })); + } + } +); diff --git a/src/index.scss b/src/index.scss index 11ccaf4..d6689f9 100644 --- a/src/index.scss +++ b/src/index.scss @@ -128,7 +128,6 @@ $white: #fff; .font-size-12{ font-size: 12px; } - .font-size-9{ font-size: 9px; } @@ -155,7 +154,7 @@ $white: #fff; .expandable{ position: relative !important; margin-left: 4px; - padding: 2px 6px; + padding: 2px 5px; border-radius: 10rem; font-size: 9px; } From 7ab55175b5c255bd6c18dae2c5ed3479f1f6e2d8 Mon Sep 17 00:00:00 2001 From: Awais Ansari Date: Thu, 1 Jun 2023 19:57:45 +0500 Subject: [PATCH 13/24] fix: redux structure updates --- src/Notifications/NotificationTabs.jsx | 2 +- src/Notifications/data/api.js | 222 ++-------------------- src/Notifications/data/notifications.json | 94 +++++++++ src/Notifications/data/slice.js | 35 +++- src/Notifications/data/thunks.js | 11 +- src/Notifications/utils.js | 24 +++ 6 files changed, 169 insertions(+), 219 deletions(-) create mode 100644 src/Notifications/data/notifications.json create mode 100644 src/Notifications/utils.js diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 4e9cdd2..43d0e8e 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -33,7 +33,7 @@ const NotificationTabs = () => { setPage(page + 1); }, [page]); - const tabArray = useMemo(() => notificationTabsOptions.map((option) => ( + const tabArray = useMemo(() => notificationTabsOptions?.map((option) => ( `${getConfig().LMS_BASE_URL}/api/notifications/count/`; export const getNotificationsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`; export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-notifications-unseen/${appName}/`; -const parseNotificationList = (notificationList) => { - const currentTime = Date.now(); - const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000); - const today = []; - const earlier = []; - notificationList.forEach(obj => { - const objectTime = obj.time; - if (objectTime >= twentyFourHoursAgo && objectTime <= currentTime) { today.push(obj); } else { earlier.push(obj); } - }); - return { today, earlier }; -}; - export async function getNotifications(appName, notificationCount, page, pageSize) { - const params = snakeCaseObject({ page, pageSize }); + // const params = snakeCaseObject({ page, pageSize }); + // const { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params }); + const data = notificationsList.notifications; - let { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params }); - - const notificationData = [ - { - type: 'post', - responding_user: 'SCM_Lead', - notification_content: 'Hello and welcome to SC0x!', - target_user: '', - course_name: 'Supply Chain Analytics', - content_content_url: '', - is_read: false, - is_seen: false, - time: '1685361282018', - }, - { - type: 'help', - responding_user: 'MITx_Learner', - notification_content: 'What grade does a student need to get in order to pass the course and earn a certificate?', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1685361282018', - }, - { - type: 'post', - responding_user: 'SCM_Lead', - notification_content: 'Hello and welcome to SC0x!', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1684253634808', - author: '', - }, - { - type: 'help', - responding_user: 'MITx_Learner', - notification_content: 'What grade does a student need to get in order to pass the course and earn a certificate?', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1684253736371', - author: '', - }, - { - type: 'respond', - responding_user: 'MITx_Learner', - notification_content: 'Can’t find linear regression in section 3 review', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1684253736371', - author: '', - }, - { - type: 'comment', - responding_user: 'MITx_Learner', - notification_content: 'Can’t find linear regression in section 3 review', - target_user: 'MITx_Expert’s ', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1684253736371', - author: '', - }, - { - type: 'question', - responding_user: 'MITx_Learner', - notification_content: 'Examples of quadratic equations in supply chains', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1684253736371', - author: '', - }, - { - type: 'comment', - responding_user: 'MITx_Learner', - notification_content: 'What grade does a student need to get in order to pass the course and earn a certificate?', - target_user: 'MITx_Expert’s ', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - time: '1684253736371', - is_seen: false, - author: 'testuser', - }, - { - type: 'comment', - responding_user: 'MITx_Learner', - notification_content: 'Convexity of f(x)=1/x , x>1', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - time: '1684253736371', - is_seen: false, - author: 'testuser', - }, - { - type: 'answer', - responding_user: 'SCM_Lead', - notification_content: 'Quiz in section 3 - Please explain the F-Significance value', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - time: '1685096268835', - is_seen: false, - author: 'testuser', - }, - { - type: 'endorsed', - responding_user: '', - notification_content: 'Quiz in section 3 - Please explain the F-Significance value', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1685096268835', - author: 'testuser', - }, - { - type: 'reported', - responding_user: 'MITx Learner’s', - notification_content: '“Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1685212056931', - author: '', - }, - { - type: 'postLiked', - responding_user: 'SCM_Lead', - notification_content: 'Retaking the course', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1685212056931', - author: '', - }, - { - type: 'commentLiked', - responding_user: 'MITx_Expert ', - notification_content: 'Final exam answers', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1685212056931', - author: '', - }, - { - type: 'edited', - responding_user: 'MITx_Expert ', - notification_content: 'Question 1', - target_user: '', - course_name: 'Supply Chain Analytics', - content_url: '', - is_read: false, - is_seen: false, - time: '1685212056931', - author: '', - }, - ]; - - const { today, earlier } = parseNotificationList(camelCaseObject(notificationData)); + const { today, earlier } = splitNotificationsByTime(camelCaseObject(data)); data = { discussions: { TODAY: today, @@ -235,21 +45,21 @@ export async function getNotifications(appName, notificationCount, page, pageSiz } export async function getNotificationCounts() { - let { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl()); - data = { - count: 25, + // const { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl()); + const data = { + count: 40, count_by_app_name: { reminders: 10, - discussions: 5, - grades: 4, - authoring: 6, + discussions: 20, + grades: 5, + authoring: 5, }, }; - return camelCaseObject(data); + + return data; } export async function markNotificationSeen(appName) { - const { data } = await getAuthenticatedHttpClient() - .put(`${markNotificationsSeenApiUrl(appName)}`); + const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`); return camelCaseObject(data); } diff --git a/src/Notifications/data/notifications.json b/src/Notifications/data/notifications.json new file mode 100644 index 0000000..1e7ceeb --- /dev/null +++ b/src/Notifications/data/notifications.json @@ -0,0 +1,94 @@ +{ + "data": [ + { + "id": 1, + "type": "post", + "content": "

SCM_Lead posts Hello and welcome to SC0x!

", + "course_name": "Supply Chain Analytics", + "content_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_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_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_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_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_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_content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + }, + { + "id": 8, + "type": "comment", + "content": "

MITx_Learner commented Examples of quadratic equations in supply chains

", + "course_name": "Supply Chain Analytics", + "content_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_content_url": "", + "last_read": null, + "last_seen": null, + "created_at": "2023-06-01T00:36:11.979531Z" + } + ] +} diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js index ac0fa6c..7d92d4d 100644 --- a/src/Notifications/data/slice.js +++ b/src/Notifications/data/slice.js @@ -1,18 +1,44 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +export const IDLE = 'idle'; export const LOADING = 'loading'; export const LOADED = 'loaded'; export const FAILED = 'failed'; export const DENIED = 'denied'; - +// today or earlier logic will shift on component level const slice = createSlice({ name: 'notifications', initialState: { notificationStatus: 'idle', - notifications: {}, - totalUnseenCounts: {}, - appName: '', + appName: 'discussions', + appsId: ['reminders', 'discussions', 'grades', 'authoring'], + apps: { + reminders: ['notification_1', 'notification_2'], + discussions: ['notification_3'], + grades: ['notification_4', 'notification_5'], + authoring: ['notification_6'], + }, + notifications: { + notification_1: {}, + notification_2: {}, + notification_3: {}, + notification_4: {}, + notification_5: {}, + notification_6: {}, + }, + tabsCount: { + reminders: 0, + discussions: 0, + grades: 0, + authoring: 0, + totalCount: 0, + }, + pagination: { + count: 90, + numPages: 9, + currentPage: 1, + }, }, reducers: { fetchNotificationDenied: (state, { payload }) => { @@ -43,7 +69,6 @@ const slice = createSlice({ fetchNotificationsCountSuccess: (state, { payload }) => { state.tabsCount = payload; state.notificationStatus = LOADED; - state.totalUnseenCounts = payload; }, markNotificationsAsSeenRequest: (state) => { state.notificationStatus = LOADING; diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index 4c61ee4..85bfb5a 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -1,3 +1,4 @@ +import { camelCaseObject } from '@edx/frontend-platform'; import { fetchNotificationSuccess, fetchNotificationRequest, @@ -9,11 +10,7 @@ import { markNotificationsAsSeenSuccess, markNotificationsAsSeenFailure, } from './slice'; -import { - getNotifications, - getNotificationCounts, - markNotificationSeen, -} from './api'; +import { getNotifications, getNotificationCounts, markNotificationSeen } from './api'; export const fetchNotificationList = ({ appName, notificationCount, page, pageSize, @@ -29,12 +26,12 @@ export const fetchNotificationList = ({ } ); -export const fetchNotificationsCountsList = () => ( +export const fetchAppsNotificationCount = () => ( async (dispatch) => { try { dispatch(fetchNotificationsCountRequest()); const data = await getNotificationCounts(); - dispatch(fetchNotificationsCountSuccess(data)); + dispatch(fetchNotificationsCountSuccess(camelCaseObject(data))); } catch (errors) { dispatch(fetchNotificationsCountFailure()); } diff --git a/src/Notifications/utils.js b/src/Notifications/utils.js new file mode 100644 index 0000000..dea9f30 --- /dev/null +++ b/src/Notifications/utils.js @@ -0,0 +1,24 @@ +export const splitNotificationsByTime = (notificationList) => { + const currentTime = Date.now(); + const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000); + + const { today, earlier } = notificationList.reduce( + (result, 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: [] }, + ); + + return { today, earlier }; +}; + +export const getNotificationCount = (notificationCounts, appName) => { + const { countByAppName } = notificationCounts; + return countByAppName[appName] || 0; +}; From 327649652305dce2b17f898d3199b2b482848166 Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Mon, 5 Jun 2023 10:14:32 +0500 Subject: [PATCH 14/24] feat: added redux store implementation --- src/Notifications/NotificationRowItem.jsx | 90 +++++--------- src/Notifications/NotificationSections.jsx | 71 ++++++----- src/Notifications/NotificationTabs.jsx | 44 +++---- src/Notifications/Notifications.jsx | 41 +++---- src/Notifications/data/api.js | 61 ++++------ src/Notifications/data/constants.js | 32 ----- src/Notifications/data/notifications.json | 58 +++++++-- src/Notifications/data/selectors.js | 9 +- src/Notifications/data/slice.js | 110 ++++++++++++------ src/Notifications/data/thunks.js | 81 +++++++++++-- src/Notifications/messages.js | 70 ----------- src/Notifications/utils.js | 62 +++++++--- src/index.scss | 18 +++ .../AuthenticatedUserDropdown.jsx | 18 ++- 14 files changed, 411 insertions(+), 354 deletions(-) delete mode 100644 src/Notifications/data/constants.js diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx index ef3bcb3..3957d38 100644 --- a/src/Notifications/NotificationRowItem.jsx +++ b/src/Notifications/NotificationRowItem.jsx @@ -1,58 +1,34 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable react/forbid-prop-types */ -import React, { useCallback, useContext } from 'react'; +import React, { useCallback } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@edx/paragon'; import * as timeago from 'timeago.js'; import PropTypes from 'prop-types'; -import { AppContext } from '@edx/frontend-platform/react'; -import { - CheckCircle, HelpOutline, QuestionAnswerOutline, Verified, Report, EditOutline, ThumbUpOutline, PostOutline, -} from '@edx/paragon/icons'; +import { useDispatch } from 'react-redux'; import { messages } from './messages'; import timeLocale from '../common/time-locale'; +import { markNotificationsAsRead } from './data/thunks'; +import { getIconByType } from './utils'; const NotificationRowItem = ({ notification }) => { const intl = useIntl(); timeago.register('time-locale', timeLocale); - const { authenticatedUser } = useContext(AppContext); + const dispatch = useDispatch(); - 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] || null; - }; + const handleRedirectToURL = useCallback(() => { + dispatch(markNotificationsAsRead(notification.id)); + window.open(notification.contentUrl, '_blank'); + }, [notification]); - const getContentMessageByType = useCallback(() => { - const contentMessage = { - post: messages.notificationPostedContent, - help: messages.notificationHelpedContent, - respond: (authenticatedUser && authenticatedUser.username) !== notification.author - ? messages.notificationResponseOnOtherPostLabel : null, - comment: notification.targetUser - ? messages.notificationCommentedOnLabel : messages.notificationCommentedOnYourPostLabel, - question: messages.notificationQuestionLabel, - answer: messages.notificationAnswerLabel, - endorsed: messages.notificationEndorsedLabel, - reported: messages.notificationReportedLabel, - postLiked: messages.notificationPostLikedLabel, - commentLiked: messages.notificationCommentLikedLabel, - edited: messages.notificationEditedLabel, - }; - return contentMessage[notification.type] ? intl.formatMessage(contentMessage[notification.type]) : null; - }, [authenticatedUser, notification, intl]); + const handleMarkAllAsRead = useCallback(() => { + dispatch(markNotificationsAsRead(notification.id)); + }, [notification.id]); const iconComponent = getIconByType(notification.type); + return (
{ style={{ height: '23.33px', width: '23.33px' }} className={iconComponent && `${iconComponent.class} mr-4`} /> -
+
-
- - {notification?.respondingUser } {' '} - {getContentMessageByType()} - {notification?.targetUser && ( - <> - {notification.targetUser} - - {(authenticatedUser && authenticatedUser.username) !== notification.author - ? intl.formatMessage(messages.notificationResponseOnOtherPostLabel) - : intl.formatMessage(messages.notificationResponseOnYourPostLabel)} - - - )} - - {' '}{notification?.notificationContent} - - -
+
+ +
{notification?.courseName} {intl.formatMessage(messages.fullStop)} - {timeago.format(notification?.time, 'time-locale')} + {timeago.format(notification?.createdAt, 'time-locale')}
- {!notification.isRead && ( -
+ {!notification.lastRead && ( +
)} diff --git a/src/Notifications/NotificationSections.jsx b/src/Notifications/NotificationSections.jsx index 1cd3818..b40f873 100644 --- a/src/Notifications/NotificationSections.jsx +++ b/src/Notifications/NotificationSections.jsx @@ -1,60 +1,76 @@ -import React from 'react'; +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import React, { useCallback } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { Button } from '@edx/paragon'; import PropTypes from 'prop-types'; import { messages } from './messages'; import NotificationRowItem from './NotificationRowItem'; -import { getNotifications } from './data/selectors'; +import { + getSelectedAppNotificationIds, getSelectedAppName, getNotificationsByIds, getPaginationData, +} from './data/selectors'; +import { splitNotificationsByTime } from './utils'; +import { markAllNotificationsAsRead } from './data/thunks'; -const NotificationSections = ({ handleLoadMoreNotification, loadMoreCount }) => { +const NotificationSections = ({ handleLoadMoreNotification }) => { const intl = useIntl(); - const notifications = useSelector(getNotifications()); - const { TODAY, EARLIER, totalCount } = notifications || {}; + const selectedAppName = useSelector(getSelectedAppName()); + const notificationIds = useSelector(getSelectedAppNotificationIds(selectedAppName)); + const notifications = useSelector(getNotificationsByIds(notificationIds)); + const paginationData = useSelector(getPaginationData()); + const { today = [], earlier = [] } = splitNotificationsByTime(notifications); + const dispatch = useDispatch(); + + const handleMarkAllAsRead = useCallback(() => { + dispatch(markAllNotificationsAsRead(selectedAppName)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAppName]); return ( notifications && (
- {TODAY && TODAY.length > 0 && ( + {today.length > 0 && ( <> { intl.formatMessage(messages.notificationTodayHeading)} - {totalCount > 0 && ( - + {today.length + earlier.length > 0 && ( + {intl.formatMessage(messages.notificationMarkAsRead)} )} )}
- {TODAY && TODAY.map( + {today.map( (notification) => , )} +
- {EARLIER && EARLIER.length > 0 + {earlier.length > 0 && intl.formatMessage(messages.notificationEarlierHeading)} - {totalCount > 0 && TODAY && TODAY.length === 0 && ( - - {intl.formatMessage(messages.notificationMarkAsRead)} - + {today.length + earlier.length > 0 && today.length === 0 && ( + + {intl.formatMessage(messages.notificationMarkAsRead)} + )}
- {EARLIER && EARLIER.map( - (notification) => , - )} - {loadMoreCount < totalCount && ( - - )} + {earlier.map( + (notification) => , + )} + {paginationData.currentPage < paginationData.numPages && ( + + )}
) ); @@ -62,7 +78,6 @@ const NotificationSections = ({ handleLoadMoreNotification, loadMoreCount }) => NotificationSections.propTypes = { handleLoadMoreNotification: PropTypes.func.isRequired, - loadMoreCount: PropTypes.number.isRequired, }; export default React.memo(NotificationSections); diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 43d0e8e..9540be3 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -4,46 +4,46 @@ import React, { import { Tabs, Tab } from '@edx/paragon'; import { useSelector, useDispatch } from 'react-redux'; import NotificationSections from './NotificationSections'; -import { getNotificationTotalUnseenCounts, getSelectedAppName } from './data/selectors'; +import { getNotificationTabsCount, getSelectedAppName, getNotificationTabs } from './data/selectors'; import { fetchNotificationList, markNotificationsAsSeen } from './data/thunks'; -import { notificationTabsOptions } from './data/constants'; const NotificationTabs = () => { - const notificationUnseenCounts = useSelector(getNotificationTotalUnseenCounts()); - const selectedappName = useSelector(getSelectedAppName()); - const [activeTab, setActiveTab] = useState(notificationTabsOptions[0].key); - const [loadMoreCount, setLoadMoreCount] = useState(10); + const notificationUnseenCounts = useSelector(getNotificationTabsCount()); + const notificationTabs = useSelector(getNotificationTabs()); + const selectedAppName = useSelector(getSelectedAppName()); + const [activeTab, setActiveTab] = useState(selectedAppName); const [page, setPage] = useState(1); - const dispatch = useDispatch(); useEffect(() => { - dispatch(fetchNotificationList({ - appName: activeTab, notificationCount: loadMoreCount, page, pageSize: 10, - })); - dispatch(markNotificationsAsSeen(activeTab)); - }, [dispatch, activeTab, loadMoreCount, page]); + dispatch(fetchNotificationList({ appName: activeTab, page, pageSize: 10 })); + if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, page, selectedAppName]); const handleActiveTab = useCallback((tab) => { setActiveTab(tab); + setPage(1); }, []); - const handleLoadMoreNotification = useCallback((count) => { - setLoadMoreCount(count); + const handleLoadMoreNotification = useCallback(() => { setPage(page + 1); }, [page]); - const tabArray = useMemo(() => notificationTabsOptions?.map((option) => ( + const tabArray = useMemo(() => notificationTabs?.map((option) => ( - {option.key === selectedappName - && } + {option === selectedAppName && ( + + )} - )), [notificationUnseenCounts, handleLoadMoreNotification, loadMoreCount, selectedappName]); + )), [notificationUnseenCounts, handleLoadMoreNotification, selectedAppName, notificationTabs]); // This code is used to replace More... text to More to match the UI const buttons = document.getElementsByClassName('dropdown-toggle'); diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/Notifications.jsx index c7381ab..ddf1124 100644 --- a/src/Notifications/Notifications.jsx +++ b/src/Notifications/Notifications.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/exhaustive-deps */ import React, { useState, useCallback, useEffect, useRef, } from 'react'; @@ -9,8 +10,8 @@ import { useSelector, useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import classNames from 'classnames'; import NotificationTabs from './NotificationTabs'; -import { getNotificationTotalUnseenCounts, getNotificationStatus } from './data/selectors'; -import { fetchNotificationsCountsList } from './data/thunks'; +import { getNotificationTabsCount } from './data/selectors'; +import { resetNotificationState } from './data/thunks'; import { messages } from './messages'; import { useIsOnDesktop, useIsOnXLDesktop } from './data/hook'; @@ -20,32 +21,23 @@ const Notifications = () => { const popoverRef = useRef(null); const buttonRef = useRef(null); const dispatch = useDispatch(); - const notificationStatus = useSelector(getNotificationStatus()); - const notificationCounts = useSelector(getNotificationTotalUnseenCounts()); + const notificationCounts = useSelector(getNotificationTabsCount()); const isOnDesktop = useIsOnDesktop(); const isOnXLDesktop = useIsOnXLDesktop(); - useEffect(() => { - if (notificationStatus === 'idle') { - dispatch(fetchNotificationsCountsList()); - } - }, [dispatch, notificationStatus]); - const handleNotificationTray = useCallback((value) => { setShowNotificationTray(value); + if (!value) { dispatch(resetNotificationState()); } + }, []); + + const handleClickOutside = useCallback((event) => { + if (popoverRef.current?.contains(event.target) !== true && buttonRef.current?.contains(event.target) !== true) { + setShowNotificationTray(false); + dispatch(resetNotificationState()); + } }, []); useEffect(() => { - const handleClickOutside = (event) => { - if ( - popoverRef.current - && buttonRef.current - && !popoverRef.current.contains(event.target) - && !buttonRef.current.contains(event.target) - ) { - setShowNotificationTray(false); - } - }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); @@ -61,7 +53,6 @@ const Notifications = () => { overlay={( { data-testid="notificationbar" >
- +

{intl.formatMessage(messages.notificationTitle)}

@@ -95,8 +83,7 @@ const Notifications = () => { iconAs={Icon} variant="light" iconClassNames="text-primary-500" - className="ml-4 mr-1 my-3" - style={{ width: '36px', height: '36px' }} + className="ml-4 mr-1 my-3 notification-button" /> `${getConfig().LMS_BASE_URL}/ap export const getNotificationsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`; export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-notifications-unseen/${appName}/`; -export async function getNotifications(appName, notificationCount, page, pageSize) { +export async function getNotifications(appName, page, pageSize) { // const params = snakeCaseObject({ page, pageSize }); // const { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params }); - const data = notificationsList.notifications; + const { data } = notificationsList; + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; - const { today, earlier } = splitNotificationsByTime(camelCaseObject(data)); - data = { - discussions: { - TODAY: today, - EARLIER: earlier, - }, - reminders: { - TODAY: today, - EARLIER: earlier, - }, - }; - - const notifications = data[appName]; - const { TODAY = [], EARLIER = [] } = notifications || []; - let todayNotifications = TODAY; - let earlierNotifications = []; - let totalCount = 0; - - if (TODAY && EARLIER) { - if (TODAY.length > notificationCount) { - todayNotifications = TODAY.slice(0, notificationCount); - } else { - todayNotifications = TODAY; - earlierNotifications = EARLIER.slice(0, notificationCount - TODAY.length); - } - totalCount = TODAY.length + EARLIER.length; - } - - return { TODAY: todayNotifications, EARLIER: earlierNotifications, totalCount }; + const notifications = data.slice(startIndex, endIndex); + return { notifications: camelCaseObject(notifications), numPages: 2, currentPage: page }; } export async function getNotificationCounts() { // const { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl()); const data = { - count: 40, + count: 45, count_by_app_name: { reminders: 10, discussions: 20, - grades: 5, + grades: 10, authoring: 5, }, + show_notification_tray: true, }; - - return data; + return camelCaseObject(data); } export async function markNotificationSeen(appName) { - const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`); + // const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`); + 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/constants.js b/src/Notifications/data/constants.js deleted file mode 100644 index 6a176ef..0000000 --- a/src/Notifications/data/constants.js +++ /dev/null @@ -1,32 +0,0 @@ -export const notificationTabs = { - REMINDERS: 'reminders', - DISCUSSIONS: 'discussions', - GRADES: 'grades', - AUTHORING: 'authoring', -}; - -export const notificationTabsLabel = { - [notificationTabs.REMINDERS]: 'Reminders', - [notificationTabs.DISCUSSIONS]: 'Discussions', - [notificationTabs.GRADES]: 'Grades', - [notificationTabs.AUTHORING]: 'Authoring', -}; - -export const notificationTabsOptions = [ - { - key: notificationTabs.REMINDERS, - title: notificationTabsLabel[notificationTabs.REMINDERS], - }, - { - key: notificationTabs.DISCUSSIONS, - title: notificationTabsLabel[notificationTabs.DISCUSSIONS], - }, - { - key: notificationTabs.GRADES, - title: notificationTabsLabel[notificationTabs.GRADES], - }, - { - key: notificationTabs.AUTHORING, - title: notificationTabsLabel[notificationTabs.AUTHORING], - }, -]; diff --git a/src/Notifications/data/notifications.json b/src/Notifications/data/notifications.json index 1e7ceeb..581c786 100644 --- a/src/Notifications/data/notifications.json +++ b/src/Notifications/data/notifications.json @@ -5,7 +5,7 @@ "type": "post", "content": "

SCM_Lead posts Hello and welcome to SC0x!

", "course_name": "Supply Chain Analytics", - "content_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:46:11.979531Z" @@ -15,7 +15,7 @@ "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_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:36:11.979531Z" @@ -25,7 +25,7 @@ "type": "post", "content": "

SCM_Lead posts Hello and welcome to SC0x!

", "course_name": "Supply Chain Analytics", - "content_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:46:11.979531Z" @@ -35,7 +35,7 @@ "type": "respond", "content": "

MITx_Learner responded Can't find linear regression in section 3 review

", "course_name": "Supply Chain Analytics", - "content_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:36:11.979531Z" @@ -45,7 +45,7 @@ "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_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:36:11.979531Z" @@ -55,7 +55,7 @@ "type": "question", "content": "

MITx_Learner commented Examples of quadratic equations in supply chains

", "course_name": "Supply Chain Analytics", - "content_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:36:11.979531Z" @@ -65,7 +65,7 @@ "type": "answer", "content": "

MITx_Expert answered Examples of quadratic equations in supply chains

", "course_name": "Supply Chain Analytics", - "content_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:36:11.979531Z" @@ -75,7 +75,7 @@ "type": "comment", "content": "

MITx_Learner commented Examples of quadratic equations in supply chains

", "course_name": "Supply Chain Analytics", - "content_content_url": "", + "content_url": "", "last_read": null, "last_seen": null, "created_at": "2023-06-01T00:36:11.979531Z" @@ -85,7 +85,47 @@ "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_content_url": "", + "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 index 295dfc1..ee9eb0e 100644 --- a/src/Notifications/data/selectors.js +++ b/src/Notifications/data/selectors.js @@ -1,4 +1,9 @@ export const getNotificationStatus = () => state => state.notifications.notificationStatus; -export const getNotificationTotalUnseenCounts = () => state => state.notifications.totalUnseenCounts; -export const getNotifications = () => state => state.notifications.notifications; +export const getNotificationTabsCount = () => state => state.notifications.tabsCount; +export const getNotificationTabs = () => state => state.notifications.appsId; +export const getSelectedAppNotificationIds = (appName) => state => state.notifications.apps[appName] ?? []; +export const getNotificationTrayStatus = () => state => state.notifications.showNotificationTray; +export const getNotificationsByIds = (notificationIds) => state => Object.entries(state.notifications.notifications) + .filter(([key]) => notificationIds.includes(key)).map(([, value]) => value); export const getSelectedAppName = () => state => state.notifications.appName; +export const getPaginationData = () => state => state.notifications.pagination; diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js index 7d92d4d..7e6373b 100644 --- a/src/Notifications/data/slice.js +++ b/src/Notifications/data/slice.js @@ -6,40 +6,24 @@ export const LOADING = 'loading'; export const LOADED = 'loaded'; export const FAILED = 'failed'; export const DENIED = 'denied'; -// today or earlier logic will shift on component level + +const initialState = { + notificationStatus: 'idle', + appName: 'reminders', + appsId: [], + apps: {}, + notifications: {}, + tabsCount: {}, + showNotificationTray: false, + pagination: { + count: 10, + numPages: 1, + currentPage: 1, + }, +}; const slice = createSlice({ name: 'notifications', - initialState: { - notificationStatus: 'idle', - appName: 'discussions', - appsId: ['reminders', 'discussions', 'grades', 'authoring'], - apps: { - reminders: ['notification_1', 'notification_2'], - discussions: ['notification_3'], - grades: ['notification_4', 'notification_5'], - authoring: ['notification_6'], - }, - notifications: { - notification_1: {}, - notification_2: {}, - notification_3: {}, - notification_4: {}, - notification_5: {}, - notification_6: {}, - }, - tabsCount: { - reminders: 0, - discussions: 0, - grades: 0, - authoring: 0, - totalCount: 0, - }, - pagination: { - count: 90, - numPages: 9, - currentPage: 1, - }, - }, + initialState, reducers: { fetchNotificationDenied: (state, { payload }) => { state.appName = payload.appName; @@ -50,12 +34,24 @@ const slice = createSlice({ state.notificationStatus = FAILED; }, fetchNotificationRequest: (state, { payload }) => { + if (state.appName !== payload.appName) { state.apps[payload.appName] = []; } state.appName = payload.appName; state.notificationStatus = LOADING; }, fetchNotificationSuccess: (state, { payload }) => { - state.notifications = payload; + const { notifications, numPages, currentPage } = payload; + const newNotificationIds = notifications.map(notification => notification.id.toString()); + const existingNotificationIds = state.apps[state.appName]; + const notificationsKeyValuePair = notifications.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {}); + const currentAppCount = state.tabsCount[state.appName]; + + state.apps[state.appName] = Array.from(new Set([...existingNotificationIds, ...newNotificationIds])); + state.notifications = { ...state.notifications, ...notificationsKeyValuePair }; + state.tabsCount.count -= currentAppCount; + state.tabsCount[state.appName] = 0; state.notificationStatus = LOADED; + state.pagination.numPages = numPages; + state.pagination.currentPage = currentPage; }, fetchNotificationsCountDenied: (state) => { state.notificationStatus = DENIED; @@ -67,7 +63,11 @@ const slice = createSlice({ state.notificationStatus = LOADING; }, fetchNotificationsCountSuccess: (state, { payload }) => { - state.tabsCount = payload; + const { countByAppName, count, showNotificationTray } = payload; + state.tabsCount = { count, ...countByAppName }; + state.appsId = Object.keys(countByAppName); + state.apps = Object.fromEntries(Object.keys(countByAppName).map(key => [key, []])); + state.showNotificationTray = showNotificationTray; state.notificationStatus = LOADED; }, markNotificationsAsSeenRequest: (state) => { @@ -82,6 +82,39 @@ const slice = createSlice({ markNotificationsAsSeenFailure: (state) => { state.notificationStatus = FAILED; }, + markAllNotificationsAsReadRequest: (state) => { + state.notificationStatus = LOADING; + }, + markAllNotificationsAsReadSuccess: (state) => { + const date = new Date().toISOString(); + const updatedNotifications = Object.entries(state.notifications) + .filter(([key]) => state.apps[state.appName].includes(key)) + .map(([, value]) => ({ ...value, lastRead: date })); + + state.notifications = updatedNotifications; + state.notificationStatus = LOADED; + }, + markAllNotificationsAsReadDenied: (state) => { + state.notificationStatus = DENIED; + }, + markAllNotificationsAsReadFailure: (state) => { + state.notificationStatus = FAILED; + }, + markNotificationsAsReadRequest: (state) => { + state.notificationStatus = LOADING; + }, + markNotificationsAsReadSuccess: (state, { payload }) => { + const date = new Date().toISOString(); + state.notifications[payload.id] = { ...state.notifications[payload.id], lastRead: date }; + state.notificationStatus = LOADED; + }, + markNotificationsAsReadDenied: (state) => { + state.notificationStatus = DENIED; + }, + markNotificationsAsReadFailure: (state) => { + state.notificationStatus = FAILED; + }, + resetNotificationStateRequest: () => initialState, }, }); @@ -98,6 +131,15 @@ export const { markNotificationsAsSeenSuccess, markNotificationsAsSeenFailure, markNotificationsAsSeenDenied, + markAllNotificationsAsReadDenied, + markAllNotificationsAsReadRequest, + markAllNotificationsAsReadSuccess, + markAllNotificationsAsReadFailure, + markNotificationsAsReadDenied, + markNotificationsAsReadRequest, + markNotificationsAsReadSuccess, + markNotificationsAsReadFailure, + resetNotificationStateRequest, } = slice.actions; export const notificationsReducer = slice.reducer; diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index 85bfb5a..a4ff8ac 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -3,25 +3,44 @@ 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 } from './api'; +import { + getNotifications, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead, +} from './api'; +import { getHttpErrorStatus } from '../utils'; export const fetchNotificationList = ({ - appName, notificationCount, page, pageSize, + appName, page, pageSize, }) => ( async (dispatch) => { try { dispatch(fetchNotificationRequest({ appName })); - const data = await getNotifications(appName, notificationCount, page, pageSize); + const data = await getNotifications(appName, page, pageSize); dispatch(fetchNotificationSuccess(data)); - } catch (errors) { - dispatch(fetchNotificationFailure({ appName })); + } catch (error) { + if (getHttpErrorStatus(error) === 403) { + dispatch(fetchNotificationDenied(appName)); + } else { + dispatch(fetchNotificationFailure(appName)); + } } } ); @@ -32,8 +51,44 @@ export const fetchAppsNotificationCount = () => ( dispatch(fetchNotificationsCountRequest()); const data = await getNotificationCounts(); dispatch(fetchNotificationsCountSuccess(camelCaseObject(data))); - } catch (errors) { - dispatch(fetchNotificationsCountFailure()); + } 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()); + } } } ); @@ -44,8 +99,16 @@ export const markNotificationsAsSeen = (appName) => ( dispatch(markNotificationsAsSeenRequest({ appName })); const data = await markNotificationSeen(appName); dispatch(markNotificationsAsSeenSuccess(data)); - } catch (errors) { - dispatch(markNotificationsAsSeenFailure({ appName })); + } catch (error) { + if (getHttpErrorStatus(error) === 403) { + dispatch(markNotificationsAsSeenDenied()); + } else { + dispatch(markNotificationsAsSeenFailure()); + } } } ); + +export const resetNotificationState = () => ( + async (dispatch) => { dispatch(resetNotificationStateRequest()); } +); diff --git a/src/Notifications/messages.js b/src/Notifications/messages.js index 62de292..40def3f 100644 --- a/src/Notifications/messages.js +++ b/src/Notifications/messages.js @@ -22,76 +22,6 @@ export const messages = defineMessages({ defaultMessage: 'Mark all as read', description: 'Mark all Notifications as read', }, - notificationPostedContent: { - id: 'notification.posted.content', - defaultMessage: 'posted', - description: 'Display notification content for post type', - }, - notificationHelpedContent: { - id: 'notification.helped.content', - defaultMessage: 'asked', - description: 'Display notification content for help type', - }, - notificationRespondedLabel: { - id: 'notification.responded.label', - defaultMessage: 'responded to a post you’re following', - description: 'Display notification content for respond type', - }, - notificationCommentedOnLabel: { - id: 'notification.commented.on.label', - defaultMessage: 'commented on', - description: 'Display notification content for comment type', - }, - notificationResponseOnOtherPostLabel: { - id: 'notification.response.on.other.post.label', - defaultMessage: 'response on a post you’re following:', - description: 'Display notification content for comment type for other posts', - }, - notificationQuestionLabel: { - id: 'notification.question.label', - defaultMessage: 'responded to your question', - description: 'Display notification content for question type', - }, - notificationResponseOnYourPostLabel: { - id: 'notification.response.on.your.post.label', - defaultMessage: 'response to your post', - description: 'Display notification content for comment type for your post', - }, - notificationCommentedOnYourPostLabel: { - id: 'notification.commented.on.your.post.label', - defaultMessage: 'commented on your response in', - description: 'Display notification content for comment type on your response', - }, - notificationAnswerLabel: { - id: 'notification.answer.label', - defaultMessage: 'response has been marked as answer in your post', - description: 'Display notification content for answer type', - }, - notificationEndorsedLabel: { - id: 'notification.endorsed.label', - defaultMessage: 'Your response has been endorsed in', - description: 'Display notification content for endorsed type', - }, - notificationReportedLabel: { - id: 'notification.reported.label', - defaultMessage: 'post has been reported', - description: 'Display notification content for reported type', - }, - notificationPostLikedLabel: { - id: 'notification.post.liked.label', - defaultMessage: 'liked your post', - description: 'Display notification content for post liked type', - }, - notificationCommentLikedLabel: { - id: 'notification.comment.liked.label', - defaultMessage: 'liked your response in', - description: 'Display notification content for response liked type', - }, - notificationEditedLabel: { - id: 'notification.edited.label', - defaultMessage: 'edited your post', - description: 'Display notification content for edited type', - }, fullStop: { id: 'notification.fullStop', defaultMessage: '•', diff --git a/src/Notifications/utils.js b/src/Notifications/utils.js index dea9f30..c5a5655 100644 --- a/src/Notifications/utils.js +++ b/src/Notifications/utils.js @@ -1,24 +1,50 @@ +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) => { - const currentTime = Date.now(); - const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000); - - const { today, earlier } = notificationList.reduce( - (result, 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: [] }, - ); + let splittedData = []; + if (notificationList.length > 0) { + const currentTime = Date.now(); + const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000); + splittedData = notificationList.reduce( + (result, 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 getNotificationCount = (notificationCounts, appName) => { - const { countByAppName } = notificationCounts; - return countByAppName[appName] || 0; +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] || null; }; diff --git a/src/index.scss b/src/index.scss index d6689f9..0ad86fc 100644 --- a/src/index.scss +++ b/src/index.scss @@ -141,6 +141,18 @@ $white: #fff; width: 20px !important; height: 20px !important; } +.cursor-pointer{ + cursor: pointer; +} +#popover-positioned-bottom{ + max-height: calc(100% - 68px); + min-height: 1220px; + min-width: 549px; +} +.notification-button{ + width: 36px; + height: 36px; +} .notification-badge{ position: absolute; margin-top: 18px; @@ -173,6 +185,12 @@ $white: #fff; } .notification-content{ .notification-item-content{ + p{ + margin-bottom: 0px; + } + b{ + color: #00262B; + } display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index a720516..05f1540 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -7,11 +7,25 @@ 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/Notifications'; +import { getNotificationTrayStatus, getNotificationStatus } from '../Notifications/data/selectors'; +import { fetchAppsNotificationCount } from '../Notifications/data/thunks'; import messages from './messages'; const AuthenticatedUserDropdown = ({ intl, username }) => { + const showNotificationTray = useSelector(getNotificationTrayStatus()); + const notificationStatus = useSelector(getNotificationStatus()); + const dispatch = useDispatch(); + + useEffect(() => { + if (notificationStatus === 'idle') { + dispatch(fetchAppsNotificationCount()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [notificationStatus]); + const dashboardMenuItem = ( {intl.formatMessage(messages.dashboard)} @@ -21,7 +35,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => { return ( <> {intl.formatMessage(messages.help)} - + {showNotificationTray && } From 18a6840037c73591aec9ac22a2d60d8869889486 Mon Sep 17 00:00:00 2001 From: Awais Ansari Date: Mon, 5 Jun 2023 14:51:30 +0500 Subject: [PATCH 15/24] fix: Ui modifications --- src/Notifications/Notifications.jsx | 32 +++++++++++++++-------------- src/Notifications/data/hook.js | 7 ++++--- src/index.scss | 16 ++++++++++++++- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/Notifications.jsx index ddf1124..2a68268 100644 --- a/src/Notifications/Notifications.jsx +++ b/src/Notifications/Notifications.jsx @@ -13,17 +13,20 @@ import NotificationTabs from './NotificationTabs'; import { getNotificationTabsCount } from './data/selectors'; import { resetNotificationState } from './data/thunks'; import { messages } from './messages'; -import { useIsOnDesktop, useIsOnXLDesktop } from './data/hook'; +import { useIsOnMediumScreen, useIsOnLargeScreen } from './data/hook'; const Notifications = () => { - const [showNotificationTray, setShowNotificationTray] = useState(false); const intl = useIntl(); + const dispatch = useDispatch(); const popoverRef = useRef(null); const buttonRef = useRef(null); - const dispatch = useDispatch(); + const [showNotificationTray, setShowNotificationTray] = useState(false); const notificationCounts = useSelector(getNotificationTabsCount()); - const isOnDesktop = useIsOnDesktop(); - const isOnXLDesktop = useIsOnXLDesktop(); + const isOnMediumScreen = useIsOnMediumScreen(); + const isOnLargeScreen = useIsOnLargeScreen(); + + console.log('isOnMediumScreen', isOnMediumScreen); + console.log('isOnLargeScreen', isOnLargeScreen); const handleNotificationTray = useCallback((value) => { setShowNotificationTray(value); @@ -49,22 +52,21 @@ const Notifications = () => { trigger="click" key="bottom" placement="bottom" + id="notificationTray" show={showNotificationTray} overlay={(
- -

- {intl.formatMessage(messages.notificationTitle)} -

+ + {intl.formatMessage(messages.notificationTitle)} diff --git a/src/Notifications/data/hook.js b/src/Notifications/data/hook.js index 9136494..b41967a 100644 --- a/src/Notifications/data/hook.js +++ b/src/Notifications/data/hook.js @@ -1,10 +1,11 @@ import { breakpoints, useWindowSize } from '@edx/paragon'; -export function useIsOnDesktop() { +export function useIsOnMediumScreen() { const windowSize = useWindowSize(); - return windowSize.width >= breakpoints.medium.minWidth; + return breakpoints.large.maxWidth > windowSize.width && windowSize.width >= breakpoints.medium.minWidth; } -export function useIsOnXLDesktop() { + +export function useIsOnLargeScreen() { const windowSize = useWindowSize(); return windowSize.width >= breakpoints.extraLarge.minWidth; } diff --git a/src/index.scss b/src/index.scss index 0ad86fc..e8c2f9c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -123,7 +123,7 @@ $white: #fff; } .font-size-18{ - font-size: 18px; + font-size: 18px !important; } .font-size-12{ font-size: 12px; @@ -159,10 +159,20 @@ $white: #fff; margin-left: -21px; border: 2px solid #FFFFFF; } + .notification-tray-container{ + &.medium-screen { + min-width: 24.313rem; + } + + &.large-screen { + min-width: 34.313rem; + } + .dropdown-toggle::after { display: none; } + .expandable{ position: relative !important; margin-left: 4px; @@ -170,6 +180,7 @@ $white: #fff; border-radius: 10rem; font-size: 9px; } + .dropdown-toggle{ font-size: 14px; padding-top: 0px !important; @@ -179,10 +190,12 @@ $white: #fff; min-width: 6px !important; } } + .dropdown-item{ font-size: 14px; font-weight: 500; } + .notification-content{ .notification-item-content{ p{ @@ -196,6 +209,7 @@ $white: #fff; -webkit-box-orient: vertical; text-overflow: ellipsis; } + .unread{ height: 10px; width: 10px; From 78a40d47c1101e0bc59014d6c124bc55796e6d4f Mon Sep 17 00:00:00 2001 From: Awais Ansari Date: Mon, 5 Jun 2023 17:52:52 +0500 Subject: [PATCH 16/24] refactor: code and style modifications --- src/Notifications/NotificationRowItem.jsx | 48 +++++----- src/Notifications/NotificationSections.jsx | 105 ++++++++++----------- src/Notifications/NotificationTabs.jsx | 46 +++------ src/Notifications/Notifications.jsx | 31 +++--- src/Notifications/data/notifications.json | 2 +- src/index.scss | 22 +++-- 6 files changed, 121 insertions(+), 133 deletions(-) diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx index 3957d38..c66d356 100644 --- a/src/Notifications/NotificationRowItem.jsx +++ b/src/Notifications/NotificationRowItem.jsx @@ -1,36 +1,30 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -/* eslint-disable react/forbid-prop-types */ import React, { useCallback } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@edx/paragon'; import * as timeago from 'timeago.js'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; +import { Link } from 'react-router-dom'; import { messages } from './messages'; import timeLocale from '../common/time-locale'; import { markNotificationsAsRead } from './data/thunks'; import { getIconByType } from './utils'; -const NotificationRowItem = ({ notification }) => { - const intl = useIntl(); +const NotificationRowItem = ({ + id, type, contentUrl, content, courseName, createdAt, lastRead, +}) => { timeago.register('time-locale', timeLocale); + const intl = useIntl(); const dispatch = useDispatch(); - const handleRedirectToURL = useCallback(() => { - dispatch(markNotificationsAsRead(notification.id)); - window.open(notification.contentUrl, '_blank'); - }, [notification]); + const handleMarkAsRead = useCallback(() => { + dispatch(markNotificationsAsRead(id)); + }, [dispatch, id]); - const handleMarkAllAsRead = useCallback(() => { - dispatch(markNotificationsAsRead(notification.id)); - }, [notification.id]); - - const iconComponent = getIconByType(notification.type); + const iconComponent = getIconByType(type); return ( -
+ { />
-
+
- {notification?.courseName} + {courseName} {intl.formatMessage(messages.fullStop)} - {timeago.format(notification?.createdAt, 'time-locale')} + {timeago.format(createdAt, 'time-locale')}
- {!notification.lastRead && ( -
+ {!lastRead && ( +
)}
-
+ ); }; NotificationRowItem.propTypes = { - notification: PropTypes.object.isRequired, + 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 index b40f873..d9aa446 100644 --- a/src/Notifications/NotificationSections.jsx +++ b/src/Notifications/NotificationSections.jsx @@ -1,83 +1,78 @@ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useSelector, useDispatch } from 'react-redux'; import { Button } from '@edx/paragon'; -import PropTypes from 'prop-types'; +import isEmpty from 'lodash/isEmpty'; import { messages } from './messages'; import NotificationRowItem from './NotificationRowItem'; import { - getSelectedAppNotificationIds, getSelectedAppName, getNotificationsByIds, getPaginationData, + getSelectedAppNotificationIds, + getSelectedAppName, + getNotificationsByIds, + getPaginationData, } from './data/selectors'; import { splitNotificationsByTime } from './utils'; import { markAllNotificationsAsRead } from './data/thunks'; -const NotificationSections = ({ handleLoadMoreNotification }) => { +const NotificationSections = () => { const intl = useIntl(); + const dispatch = useDispatch(); const selectedAppName = useSelector(getSelectedAppName()); const notificationIds = useSelector(getSelectedAppNotificationIds(selectedAppName)); const notifications = useSelector(getNotificationsByIds(notificationIds)); const paginationData = useSelector(getPaginationData()); - const { today = [], earlier = [] } = splitNotificationsByTime(notifications); - const dispatch = useDispatch(); + const { today = [], earlier = [] } = useMemo( + () => splitNotificationsByTime(notifications), + [notifications], + ); const handleMarkAllAsRead = useCallback(() => { dispatch(markAllNotificationsAsRead(selectedAppName)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAppName]); + }, [dispatch, selectedAppName]); + + 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) && ( + + {intl.formatMessage(messages.notificationMarkAsRead)} + + )} +
+ {items.map((notification) => ( + + ))} +
+ ); + }; return ( - notifications && (
-
- {today.length > 0 && ( - <> - - { intl.formatMessage(messages.notificationTodayHeading)} - - {today.length + earlier.length > 0 && ( - - {intl.formatMessage(messages.notificationMarkAsRead)} - - )} - - )} -
- {today.map( - (notification) => , - )} - -
- - {earlier.length > 0 - && intl.formatMessage(messages.notificationEarlierHeading)} - - {today.length + earlier.length > 0 && today.length === 0 && ( - - {intl.formatMessage(messages.notificationMarkAsRead)} - - )} -
- {earlier.map( - (notification) => , - )} + {renderNotificationSection('today', today)} + {renderNotificationSection('earlier', earlier)} {paginationData.currentPage < paginationData.numPages && ( - + )}
- ) ); }; -NotificationSections.propTypes = { - handleLoadMoreNotification: PropTypes.func.isRequired, -}; - export default React.memo(NotificationSections); diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 9540be3..8468f5f 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -8,53 +8,37 @@ import { getNotificationTabsCount, getSelectedAppName, getNotificationTabs } fro import { fetchNotificationList, markNotificationsAsSeen } from './data/thunks'; const NotificationTabs = () => { + const dispatch = useDispatch(); + const [page, setPage] = useState(1); + const selectedAppName = useSelector(getSelectedAppName()); const notificationUnseenCounts = useSelector(getNotificationTabsCount()); const notificationTabs = useSelector(getNotificationTabs()); - const selectedAppName = useSelector(getSelectedAppName()); - const [activeTab, setActiveTab] = useState(selectedAppName); - const [page, setPage] = useState(1); - const dispatch = useDispatch(); useEffect(() => { - dispatch(fetchNotificationList({ appName: activeTab, page, pageSize: 10 })); + dispatch(fetchNotificationList({ appName: selectedAppName, page, pageSize: 10 })); if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab, page, selectedAppName]); + }, [dispatch, page, selectedAppName]); - const handleActiveTab = useCallback((tab) => { - setActiveTab(tab); - setPage(1); + const handleActiveTab = useCallback((appName) => { + // dispatch(setSelectedAppName(appName)); }, []); - const handleLoadMoreNotification = useCallback(() => { - setPage(page + 1); - }, [page]); - - const tabArray = useMemo(() => notificationTabs?.map((option) => ( + const tabArray = useMemo(() => notificationTabs?.map((appName) => ( - {option === selectedAppName && ( - - )} + {appName === selectedAppName && ()} - )), [notificationUnseenCounts, handleLoadMoreNotification, selectedAppName, notificationTabs]); - - // This code is used to replace More... text to More to match the UI - const buttons = document.getElementsByClassName('dropdown-toggle'); - for (let i = 0; i < buttons.length; i++) { - buttons[i].firstChild.nodeValue = 'More'; - } + )), [notificationUnseenCounts, selectedAppName, notificationTabs]); return ( diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/Notifications.jsx index 2a68268..5ebb9ef 100644 --- a/src/Notifications/Notifications.jsx +++ b/src/Notifications/Notifications.jsx @@ -25,25 +25,22 @@ const Notifications = () => { const isOnMediumScreen = useIsOnMediumScreen(); const isOnLargeScreen = useIsOnLargeScreen(); - console.log('isOnMediumScreen', isOnMediumScreen); - console.log('isOnLargeScreen', isOnLargeScreen); - - const handleNotificationTray = useCallback((value) => { - setShowNotificationTray(value); - if (!value) { dispatch(resetNotificationState()); } + const hideNotificationTray = useCallback(() => { + setShowNotificationTray(prevState => !prevState); }, []); const handleClickOutside = useCallback((event) => { - if (popoverRef.current?.contains(event.target) !== true && buttonRef.current?.contains(event.target) !== true) { + if (!popoverRef.current?.contains(event.target) && !buttonRef.current?.contains(event.target)) { setShowNotificationTray(false); - dispatch(resetNotificationState()); } }, []); useEffect(() => { document.addEventListener('mousedown', handleClickOutside); + return () => { document.removeEventListener('mousedown', handleClickOutside); + dispatch(resetNotificationState()); }; }, []); @@ -80,20 +77,22 @@ const Notifications = () => { { handleNotificationTray(!showNotificationTray); }} + onClick={hideNotificationTray} src={NotificationsNone} iconAs={Icon} variant="light" iconClassNames="text-primary-500" className="ml-4 mr-1 my-3 notification-button" /> - - { notificationCounts?.count > 0 && notificationCounts?.count} - + {notificationCounts?.count > 0 && ( + + {notificationCounts.count} + + )}
); diff --git a/src/Notifications/data/notifications.json b/src/Notifications/data/notifications.json index 581c786..87e4eb6 100644 --- a/src/Notifications/data/notifications.json +++ b/src/Notifications/data/notifications.json @@ -68,7 +68,7 @@ "content_url": "", "last_read": null, "last_seen": null, - "created_at": "2023-06-01T00:36:11.979531Z" + "created_at": "2023-06-05T00:36:11.979531Z" }, { "id": 8, diff --git a/src/index.scss b/src/index.scss index e8c2f9c..d9a607e 100644 --- a/src/index.scss +++ b/src/index.scss @@ -125,34 +125,37 @@ $white: #fff; .font-size-18{ font-size: 18px !important; } + .font-size-12{ font-size: 12px; } + .font-size-9{ font-size: 9px; } + .line-height-24{ line-height: 24px; } + .line-height-20{ line-height: 20px; } + .icon-size-20{ width: 20px !important; height: 20px !important; } + .cursor-pointer{ cursor: pointer; } -#popover-positioned-bottom{ - max-height: calc(100% - 68px); - min-height: 1220px; - min-width: 549px; -} + .notification-button{ width: 36px; height: 36px; } + .notification-badge{ position: absolute; margin-top: 18px; @@ -160,7 +163,14 @@ $white: #fff; border: 2px solid #FFFFFF; } -.notification-tray-container{ +.notification-tray-container { + max-height: calc(100% - 68px); + min-height: 1220px; + + .popover { + + } + &.medium-screen { min-width: 24.313rem; } From cabf4e3f27aeff55f4ee1571b6471fae593c7f4a Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Mon, 5 Jun 2023 19:55:46 +0500 Subject: [PATCH 17/24] refactor: fixed code refactor and added new slices and selector --- src/Notifications/NotificationRowItem.jsx | 14 +++---- src/Notifications/NotificationSections.jsx | 37 ++++++++++--------- src/Notifications/NotificationTabs.jsx | 31 ++++++++-------- src/Notifications/data/selectors.js | 32 +++++++++++----- src/Notifications/data/slice.js | 12 ++++++ .../{Notifications.jsx => index.jsx} | 22 +++++------ src/Notifications/utils.js | 12 +++--- src/index.scss | 13 +++---- .../AuthenticatedUserDropdown.jsx | 12 +++--- 9 files changed, 108 insertions(+), 77 deletions(-) rename src/Notifications/{Notifications.jsx => index.jsx} (86%) diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx index c66d356..136420a 100644 --- a/src/Notifications/NotificationRowItem.jsx +++ b/src/Notifications/NotificationRowItem.jsx @@ -1,14 +1,14 @@ import React, { useCallback } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Icon } from '@edx/paragon'; -import * as timeago from 'timeago.js'; -import PropTypes from 'prop-types'; 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'; -import { markNotificationsAsRead } from './data/thunks'; -import { getIconByType } from './utils'; const NotificationRowItem = ({ id, type, contentUrl, content, courseName, createdAt, lastRead, @@ -38,7 +38,7 @@ const NotificationRowItem = ({ // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ __html: content }} /> -
+
{courseName} {intl.formatMessage(messages.fullStop)} diff --git a/src/Notifications/NotificationSections.jsx b/src/Notifications/NotificationSections.jsx index d9aa446..8e1c617 100644 --- a/src/Notifications/NotificationSections.jsx +++ b/src/Notifications/NotificationSections.jsx @@ -1,26 +1,21 @@ import React, { useCallback, useMemo } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { useSelector, useDispatch } from 'react-redux'; 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 { - getSelectedAppNotificationIds, - getSelectedAppName, - getNotificationsByIds, - getPaginationData, -} from './data/selectors'; -import { splitNotificationsByTime } from './utils'; 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(getSelectedAppName()); - const notificationIds = useSelector(getSelectedAppNotificationIds(selectedAppName)); - const notifications = useSelector(getNotificationsByIds(notificationIds)); - const paginationData = useSelector(getPaginationData()); + const selectedAppName = useSelector(selectSelectedAppName()); + const notifications = useSelector(selectNotificationsByIds); + const paginationData = useSelector(selectPaginationData()); const { today = [], earlier = [] } = useMemo( () => splitNotificationsByTime(notifications), [notifications], @@ -30,20 +25,28 @@ const NotificationSections = () => { 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) => ( @@ -67,7 +70,7 @@ const NotificationSections = () => { {renderNotificationSection('today', today)} {renderNotificationSection('earlier', earlier)} {paginationData.currentPage < paginationData.numPages && ( - )} diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 8468f5f..6dd395d 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -1,27 +1,28 @@ -import React, { - useState, useCallback, useMemo, useEffect, -} from 'react'; -import { Tabs, Tab } from '@edx/paragon'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Tab, Tabs } from '@edx/paragon'; import NotificationSections from './NotificationSections'; -import { getNotificationTabsCount, getSelectedAppName, getNotificationTabs } from './data/selectors'; 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 [page, setPage] = useState(1); - const selectedAppName = useSelector(getSelectedAppName()); - const notificationUnseenCounts = useSelector(getNotificationTabsCount()); - const notificationTabs = useSelector(getNotificationTabs()); + const selectedAppName = useSelector(selectSelectedAppName()); + const notificationUnseenCounts = useSelector(selectNotificationTabsCount()); + const notificationTabs = useSelector(selectNotificationTabs()); + const paginationData = useSelector(selectPaginationData()); useEffect(() => { - dispatch(fetchNotificationList({ appName: selectedAppName, page, pageSize: 10 })); + dispatch(fetchNotificationList({ appName: selectedAppName, page: paginationData.currentPage, pageSize: 10 })); if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); } - }, [dispatch, page, selectedAppName]); + }, [dispatch, paginationData.currentPage, selectedAppName]); const handleActiveTab = useCallback((appName) => { - // dispatch(setSelectedAppName(appName)); - }, []); + dispatch(updateAppNameRequest({ appName })); + }, [dispatch]); const tabArray = useMemo(() => notificationTabs?.map((appName) => ( { ); }; -export default NotificationTabs; +export default React.memo(NotificationTabs); diff --git a/src/Notifications/data/selectors.js b/src/Notifications/data/selectors.js index ee9eb0e..2fbb78c 100644 --- a/src/Notifications/data/selectors.js +++ b/src/Notifications/data/selectors.js @@ -1,9 +1,23 @@ -export const getNotificationStatus = () => state => state.notifications.notificationStatus; -export const getNotificationTabsCount = () => state => state.notifications.tabsCount; -export const getNotificationTabs = () => state => state.notifications.appsId; -export const getSelectedAppNotificationIds = (appName) => state => state.notifications.apps[appName] ?? []; -export const getNotificationTrayStatus = () => state => state.notifications.showNotificationTray; -export const getNotificationsByIds = (notificationIds) => state => Object.entries(state.notifications.notifications) - .filter(([key]) => notificationIds.includes(key)).map(([, value]) => value); -export const getSelectedAppName = () => state => state.notifications.appName; -export const getPaginationData = () => state => state.notifications.pagination; +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.notification; + +export const selectNotificationsByIds = createSelector( + state => state.notifications.notifications, + state => state.notifications.apps[state.notifications.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 index 7e6373b..b32c27f 100644 --- a/src/Notifications/data/slice.js +++ b/src/Notifications/data/slice.js @@ -19,6 +19,7 @@ const initialState = { count: 10, numPages: 1, currentPage: 1, + nextPage: null, }, }; const slice = createSlice({ @@ -115,6 +116,15 @@ const slice = createSlice({ state.notificationStatus = FAILED; }, resetNotificationStateRequest: () => initialState, + + updateAppNameRequest: (state, { payload }) => { + state.appName = payload.appName; + state.pagination.currentPage = 1; + }, + + updatePaginationRequest: (state) => { + state.pagination.currentPage += 1; + }, }, }); @@ -140,6 +150,8 @@ export const { markNotificationsAsReadSuccess, markNotificationsAsReadFailure, resetNotificationStateRequest, + updateAppNameRequest, + updatePaginationRequest, } = slice.actions; export const notificationsReducer = slice.reducer; diff --git a/src/Notifications/Notifications.jsx b/src/Notifications/index.jsx similarity index 86% rename from src/Notifications/Notifications.jsx rename to src/Notifications/index.jsx index 5ebb9ef..419cc4b 100644 --- a/src/Notifications/Notifications.jsx +++ b/src/Notifications/index.jsx @@ -1,19 +1,19 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { - useState, useCallback, useEffect, useRef, + useCallback, useEffect, useRef, useState, } from 'react'; -import { NotificationsNone, Settings } from '@edx/paragon/icons'; +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 { useSelector, useDispatch } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import classNames from 'classnames'; -import NotificationTabs from './NotificationTabs'; -import { getNotificationTabsCount } from './data/selectors'; +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'; -import { useIsOnMediumScreen, useIsOnLargeScreen } from './data/hook'; const Notifications = () => { const intl = useIntl(); @@ -21,7 +21,7 @@ const Notifications = () => { const popoverRef = useRef(null); const buttonRef = useRef(null); const [showNotificationTray, setShowNotificationTray] = useState(false); - const notificationCounts = useSelector(getNotificationTabsCount()); + const notificationCounts = useSelector(selectNotificationTabsCount()); const isOnMediumScreen = useIsOnMediumScreen(); const isOnLargeScreen = useIsOnLargeScreen(); @@ -55,7 +55,7 @@ const Notifications = () => { { {notificationCounts.count} diff --git a/src/Notifications/utils.js b/src/Notifications/utils.js index c5a5655..d17eef3 100644 --- a/src/Notifications/utils.js +++ b/src/Notifications/utils.js @@ -17,11 +17,13 @@ export const splitNotificationsByTime = (notificationList) => { splittedData = notificationList.reduce( (result, notification) => { - const objectTime = new Date(notification.createdAt).getTime(); - if (objectTime >= twentyFourHoursAgo && objectTime <= currentTime) { - result.today.push(notification); - } else { - result.earlier.push(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; }, diff --git a/src/index.scss b/src/index.scss index d9a607e..2917197 100644 --- a/src/index.scss +++ b/src/index.scss @@ -130,8 +130,8 @@ $white: #fff; font-size: 12px; } -.font-size-9{ - font-size: 9px; +.font-size-14{ + font-size: 14px; } .line-height-24{ @@ -161,15 +161,14 @@ $white: #fff; margin-top: 18px; margin-left: -21px; border: 2px solid #FFFFFF; + font-size: 9px !important; } -.notification-tray-container { +.popover{ max-height: calc(100% - 68px); min-height: 1220px; - - .popover { - - } + 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; diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index 05f1540..680b437 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -3,24 +3,24 @@ 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/Notifications'; -import { getNotificationTrayStatus, getNotificationStatus } from '../Notifications/data/selectors'; +import Notifications from '../Notifications'; +import { selectShowNotificationTray, selectNotificationStatus } from '../Notifications/data/selectors'; import { fetchAppsNotificationCount } from '../Notifications/data/thunks'; +import { IDLE } from '../Notifications/data/slice'; import messages from './messages'; const AuthenticatedUserDropdown = ({ intl, username }) => { - const showNotificationTray = useSelector(getNotificationTrayStatus()); - const notificationStatus = useSelector(getNotificationStatus()); + const showNotificationTray = useSelector(selectShowNotificationTray()); + const notificationStatus = useSelector(selectNotificationStatus()); const dispatch = useDispatch(); useEffect(() => { - if (notificationStatus === 'idle') { + if (notificationStatus === IDLE) { dispatch(fetchAppsNotificationCount()); } // eslint-disable-next-line react-hooks/exhaustive-deps From b1feed2443b9cbfc89b1ce3acec48c12dcd55e18 Mon Sep 17 00:00:00 2001 From: ayeshoali Date: Tue, 6 Jun 2023 12:48:10 +0500 Subject: [PATCH 18/24] fix: fixes UI according to figma --- src/Notifications/NotificationRowItem.jsx | 6 +++--- src/Notifications/NotificationSections.jsx | 6 +++--- src/Notifications/NotificationTabs.jsx | 4 ++-- src/index.scss | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Notifications/NotificationRowItem.jsx b/src/Notifications/NotificationRowItem.jsx index 136420a..0d31861 100644 --- a/src/Notifications/NotificationRowItem.jsx +++ b/src/Notifications/NotificationRowItem.jsx @@ -32,13 +32,13 @@ const NotificationRowItem = ({ />
-
+
-
+
{courseName} {intl.formatMessage(messages.fullStop)} diff --git a/src/Notifications/NotificationSections.jsx b/src/Notifications/NotificationSections.jsx index 8e1c617..35ba370 100644 --- a/src/Notifications/NotificationSections.jsx +++ b/src/Notifications/NotificationSections.jsx @@ -34,15 +34,15 @@ const NotificationSections = () => { return (
-
- +
+ {section === 'today' && intl.formatMessage(messages.notificationTodayHeading)} {section === 'earlier' && intl.formatMessage(messages.notificationEarlierHeading)} {notifications?.length > 0 && (section === 'earlier' ? today.length === 0 : true) && ( diff --git a/src/Notifications/NotificationTabs.jsx b/src/Notifications/NotificationTabs.jsx index 78e704d..490ec06 100644 --- a/src/Notifications/NotificationTabs.jsx +++ b/src/Notifications/NotificationTabs.jsx @@ -1,3 +1,4 @@ +/* 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'; @@ -13,16 +14,16 @@ const NotificationTabs = () => { const selectedAppName = useSelector(selectSelectedAppName()); const notificationUnseenCounts = useSelector(selectNotificationTabsCount()); const notificationTabs = useSelector(selectNotificationTabs()); - const paginationData = useSelector(selectPaginationData()); + const { currentPage } = useSelector(selectPaginationData()); useEffect(() => { - dispatch(fetchNotificationList({ appName: selectedAppName, page: paginationData.currentPage, pageSize: 10 })); + dispatch(fetchNotificationList({ appName: selectedAppName, page: currentPage, pageSize: 10 })); if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); } - }, [dispatch, paginationData.currentPage, selectedAppName]); + }, [currentPage, selectedAppName]); const handleActiveTab = useCallback((appName) => { dispatch(updateAppNameRequest({ appName })); - }, [dispatch]); + }, []); const tabArray = useMemo(() => notificationTabs?.map((appName) => ( state => state.noti export const selectShowNotificationTray = () => state => state.notifications.showNotificationTray; -export const selectNotifications = () => state => state.notifications.notification; +export const selectNotifications = () => state => state.notifications.notifications; -export const selectNotificationsByIds = createSelector( - state => state.notifications.notifications, - state => state.notifications.apps[state.notifications.appName] || [], - (notifications, notificationIds) => notificationIds.map(notificationId => notifications[notificationId]), +export const selectNotificationsByIds = (appName) => createSelector( + selectNotifications(), + selectSelectedAppNotificationIds(appName), + (notifications, notificationIds) => notificationIds.map((notificationId) => notifications[notificationId]) || [], ); export const selectSelectedAppName = () => state => state.notifications.appName; diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js index c3fd4f5..8751475 100644 --- a/src/Notifications/data/slice.js +++ b/src/Notifications/data/slice.js @@ -1,15 +1,17 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; -export const IDLE = 'idle'; -export const LOADING = 'loading'; -export const LOADED = 'loaded'; -export const FAILED = 'failed'; -export const DENIED = 'denied'; +export const RequestStatus = { + IDLE: 'idle', + LOADING: 'in-progress', + LOADED: 'successful', + FAILED: 'failed', + DENIED: 'denied', +}; const initialState = { notificationStatus: 'idle', - appName: 'reminders', + appName: 'discussions', appsId: [], apps: {}, notifications: {}, @@ -26,65 +28,62 @@ const slice = createSlice({ name: 'notifications', initialState, reducers: { - fetchNotificationDenied: (state, { payload }) => { - state.appName = payload.appName; - state.notificationStatus = DENIED; + fetchNotificationDenied: (state) => { + state.notificationStatus = RequestStatus.DENIED; }, - fetchNotificationFailure: (state, { payload }) => { - state.appName = payload.appName; - state.notificationStatus = FAILED; + fetchNotificationFailure: (state) => { + state.notificationStatus = RequestStatus.FAILED; }, - fetchNotificationRequest: (state, { payload }) => { - if (state.appName !== payload.appName) { state.apps[payload.appName] = []; } - state.appName = payload.appName; - state.notificationStatus = LOADING; + fetchNotificationRequest: (state) => { + state.notificationStatus = RequestStatus.LOADING; }, fetchNotificationSuccess: (state, { payload }) => { - const { notifications, numPages, currentPage } = payload; - const newNotificationIds = notifications.map(notification => notification.id.toString()); + const { + newNotificationIds, notificationsKeyValuePair, numPages, currentPage, + } = payload; const existingNotificationIds = state.apps[state.appName]; - const notificationsKeyValuePair = notifications.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {}); - const currentAppCount = state.tabsCount[state.appName]; state.apps[state.appName] = Array.from(new Set([...existingNotificationIds, ...newNotificationIds])); state.notifications = { ...state.notifications, ...notificationsKeyValuePair }; - state.tabsCount.count -= currentAppCount; + state.tabsCount.count -= state.tabsCount[state.appName]; state.tabsCount[state.appName] = 0; - state.notificationStatus = LOADED; + state.notificationStatus = RequestStatus.LOADED; state.pagination.numPages = numPages; state.pagination.currentPage = currentPage; }, fetchNotificationsCountDenied: (state) => { - state.notificationStatus = DENIED; + state.notificationStatus = RequestStatus.DENIED; }, fetchNotificationsCountFailure: (state) => { - state.notificationStatus = FAILED; + state.notificationStatus = RequestStatus.FAILED; }, fetchNotificationsCountRequest: (state) => { - state.notificationStatus = LOADING; + state.notificationStatus = RequestStatus.LOADING; }, fetchNotificationsCountSuccess: (state, { payload }) => { - const { countByAppName, count, showNotificationTray } = payload; + const { + countByAppName, appIds, apps, count, showNotificationTray, + } = payload; state.tabsCount = { count, ...countByAppName }; - state.appsId = Object.keys(countByAppName); - state.apps = Object.fromEntries(Object.keys(countByAppName).map(key => [key, []])); + state.appsId = appIds; + state.apps = apps; state.showNotificationTray = showNotificationTray; - state.notificationStatus = LOADED; + state.notificationStatus = RequestStatus.LOADED; }, markNotificationsAsSeenRequest: (state) => { - state.notificationStatus = LOADING; + state.notificationStatus = RequestStatus.LOADING; }, markNotificationsAsSeenSuccess: (state) => { - state.notificationStatus = LOADED; + state.notificationStatus = RequestStatus.LOADED; }, markNotificationsAsSeenDenied: (state) => { - state.notificationStatus = DENIED; + state.notificationStatus = RequestStatus.DENIED; }, markNotificationsAsSeenFailure: (state) => { - state.notificationStatus = FAILED; + state.notificationStatus = RequestStatus.FAILED; }, markAllNotificationsAsReadRequest: (state) => { - state.notificationStatus = LOADING; + state.notificationStatus = RequestStatus.LOADING; }, markAllNotificationsAsReadSuccess: (state) => { const updatedNotifications = Object.fromEntries( @@ -93,27 +92,27 @@ const slice = createSlice({ ]), ); state.notifications = updatedNotifications; - state.notificationStatus = LOADED; + state.notificationStatus = RequestStatus.LOADED; }, markAllNotificationsAsReadDenied: (state) => { - state.notificationStatus = DENIED; + state.notificationStatus = RequestStatus.DENIED; }, markAllNotificationsAsReadFailure: (state) => { - state.notificationStatus = FAILED; + state.notificationStatus = RequestStatus.FAILED; }, markNotificationsAsReadRequest: (state) => { - state.notificationStatus = LOADING; + state.notificationStatus = RequestStatus.LOADING; }, markNotificationsAsReadSuccess: (state, { payload }) => { const date = new Date().toISOString(); state.notifications[payload.id] = { ...state.notifications[payload.id], lastRead: date }; - state.notificationStatus = LOADED; + state.notificationStatus = RequestStatus.LOADED; }, markNotificationsAsReadDenied: (state) => { - state.notificationStatus = DENIED; + state.notificationStatus = RequestStatus.DENIED; }, markNotificationsAsReadFailure: (state) => { - state.notificationStatus = FAILED; + state.notificationStatus = RequestStatus.FAILED; }, resetNotificationStateRequest: () => initialState, updateAppNameRequest: (state, { payload }) => { diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index a4ff8ac..1e702b2 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -1,4 +1,3 @@ -import { camelCaseObject } from '@edx/frontend-platform'; import { fetchNotificationSuccess, fetchNotificationRequest, @@ -27,14 +26,29 @@ import { } from './api'; import { getHttpErrorStatus } from '../utils'; -export const fetchNotificationList = ({ - appName, page, pageSize, -}) => ( +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); - dispatch(fetchNotificationSuccess(data)); + const normalisedData = normalizeNotifications((data)); + dispatch(fetchNotificationSuccess({ ...normalisedData, numPages: data.numPages, currentPage: data.currentPage })); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(fetchNotificationDenied(appName)); @@ -50,7 +64,13 @@ export const fetchAppsNotificationCount = () => ( try { dispatch(fetchNotificationsCountRequest()); const data = await getNotificationCounts(); - dispatch(fetchNotificationsCountSuccess(camelCaseObject(data))); + const normalisedData = normalizeNotificationCounts((data)); + dispatch(fetchNotificationsCountSuccess({ + ...normalisedData, + countByAppName: data.countByAppName, + count: data.count, + showNotificationTray: data.showNotificationTray, + })); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(fetchNotificationsCountDenied()); diff --git a/src/Notifications/index.jsx b/src/Notifications/index.jsx index 419cc4b..30d28b9 100644 --- a/src/Notifications/index.jsx +++ b/src/Notifications/index.jsx @@ -13,33 +13,33 @@ import { selectNotificationTabsCount } from './data/selectors'; import { resetNotificationState } from './data/thunks'; import { useIsOnLargeScreen, useIsOnMediumScreen } from './data/hook'; import NotificationTabs from './NotificationTabs'; -import { messages } from './messages'; +import messages from './messages'; const Notifications = () => { const intl = useIntl(); const dispatch = useDispatch(); const popoverRef = useRef(null); const buttonRef = useRef(null); - const [showNotificationTray, setShowNotificationTray] = useState(false); + const [enableNotificationTray, setEnableNotificationTray] = useState(false); const notificationCounts = useSelector(selectNotificationTabsCount()); const isOnMediumScreen = useIsOnMediumScreen(); const isOnLargeScreen = useIsOnLargeScreen(); const hideNotificationTray = useCallback(() => { - setShowNotificationTray(prevState => !prevState); + setEnableNotificationTray(prevState => !prevState); }, []); - const handleClickOutside = useCallback((event) => { + const handleClickOutsideNotificationTray = useCallback((event) => { if (!popoverRef.current?.contains(event.target) && !buttonRef.current?.contains(event.target)) { - setShowNotificationTray(false); + setEnableNotificationTray(false); } }, []); useEffect(() => { - document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('mousedown', handleClickOutsideNotificationTray); return () => { - document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('mousedown', handleClickOutsideNotificationTray); dispatch(resetNotificationState()); }; }, []); @@ -50,7 +50,7 @@ const Notifications = () => { key="bottom" placement="bottom" id="notificationTray" - show={showNotificationTray} + show={enableNotificationTray} overlay={( { >
{ commentLiked: { icon: ThumbUpOutline, class: 'text-primary-500' }, edited: { icon: EditOutline, class: 'text-primary-500' }, }; - return iconMap[type] || null; + return iconMap[type] || { icon: PostOutline, class: 'text-primary-500' }; }; diff --git a/src/common/time-locale.js b/src/common/time-locale.js index 19ae993..4a618dd 100644 --- a/src/common/time-locale.js +++ b/src/common/time-locale.js @@ -1,5 +1,4 @@ -// eslint-disable-next-line no-unused-vars -export default function timeLocale(number, index, totalSec) { +export default function timeLocale(number, index) { return [ ['just now', 'right now'], ['%ss', 'in %s seconds'], diff --git a/src/index.scss b/src/index.scss index 75abe8b..e30b11d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -122,60 +122,67 @@ $white: #fff; } } -.content{ - b{ - color: #00262B; - font-weight: 500; +.content { + + b { + color: #00262B !important; + font-weight: 500 !important; } + } -.font-size-18{ +.font-size-18 { font-size: 18px !important; } -.font-size-12{ +.font-size-12 { font-size: 12px; } -.font-size-14{ +.font-size-14 { font-size: 14px; } -.py-10px{ +.py-10px { padding-top: 10px; padding-bottom: 10px; } -.pb-10px{ +.pb-10px { padding-bottom: 10px; } -.line-height-24{ +.line-height-24 { line-height: 24px; } -.line-height-20{ +.line-height-20 { line-height: 20px; } -.line-height-10{ +.line-height-10 { line-height: 10px !important; } -.icon-size-20{ +.icon-size-20 { width: 20px !important; height: 20px !important; } -.cursor-pointer{ +.cursor-pointer { cursor: pointer; } -.notification-button{ +.notification-button { width: 36px; height: 36px; } -.notification-badge{ +.notification-icon{ + height: 23.33px !important; + width: 23.33px !important; +} + +.notification-badge { position: absolute; margin-top: 18px; margin-left: -21px; @@ -183,7 +190,7 @@ $white: #fff; font-size: 9px !important; } -.popover{ +.popover { max-height: calc(100% - 68px); min-height: 1220px; filter: none; @@ -201,7 +208,7 @@ $white: #fff; display: none; } - .expandable{ + .expandable { position: relative !important; margin-left: 4px; padding: 2px 5px; @@ -209,36 +216,42 @@ $white: #fff; font-size: 9px; } - .dropdown-toggle{ + .dropdown-toggle { font-size: 14px; padding-top: 0px !important; padding-bottom: 12px !important; - div{ + + div { min-height: 6px !important; min-width: 6px !important; } + } - .dropdown-item{ + .dropdown-item { font-size: 14px; font-weight: 500; } - .notification-content{ - .notification-item-content{ - p{ + .notification-content { + + .notification-item-content { + + p { margin-bottom: 0px; } - b{ + + b { color: #00262B; } + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; text-overflow: ellipsis; } - .unread{ + .unread { height: 10px; width: 10px; } diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index 680b437..2ac7beb 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -10,7 +10,7 @@ import { useSelector, useDispatch } from 'react-redux'; import Notifications from '../Notifications'; import { selectShowNotificationTray, selectNotificationStatus } from '../Notifications/data/selectors'; import { fetchAppsNotificationCount } from '../Notifications/data/thunks'; -import { IDLE } from '../Notifications/data/slice'; +import { RequestStatus } from '../Notifications/data/slice'; import messages from './messages'; @@ -20,7 +20,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => { const dispatch = useDispatch(); useEffect(() => { - if (notificationStatus === IDLE) { + if (notificationStatus === RequestStatus.IDLE) { dispatch(fetchAppsNotificationCount()); } // eslint-disable-next-line react-hooks/exhaustive-deps From 30e6eed60d4deafbd014580b74022c54e07d72bb Mon Sep 17 00:00:00 2001 From: ayeshoali Date: Thu, 15 Jun 2023 12:37:15 +0500 Subject: [PATCH 24/24] refactor: fixes extra spaces in index.scss --- src/index.scss | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/index.scss b/src/index.scss index e30b11d..1e1eeca 100644 --- a/src/index.scss +++ b/src/index.scss @@ -123,12 +123,10 @@ $white: #fff; } .content { - b { color: #00262B !important; font-weight: 500 !important; } - } .font-size-18 { @@ -147,6 +145,7 @@ $white: #fff; padding-top: 10px; padding-bottom: 10px; } + .pb-10px { padding-bottom: 10px; } @@ -225,7 +224,6 @@ $white: #fff; min-height: 6px !important; min-width: 6px !important; } - } .dropdown-item { @@ -234,8 +232,11 @@ $white: #fff; } .notification-content { - .notification-item-content { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; p { margin-bottom: 0px; @@ -244,11 +245,6 @@ $white: #fff; b { color: #00262B; } - - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - text-overflow: ellipsis; } .unread {