From 327649652305dce2b17f898d3199b2b482848166 Mon Sep 17 00:00:00 2001 From: SundasNoreen Date: Mon, 5 Jun 2023 10:14:32 +0500 Subject: [PATCH] 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 && }