From 8967d0a66b13a5e12de1a41864d80b8bd82c282b Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Wed, 2 Oct 2024 00:53:07 +0500 Subject: [PATCH] feat: converted notification redux structure to context API --- .../__factories__/notifications.factory.js | 3 +- src/Notifications/data/selectors.js | 2 + src/Notifications/data/slice.js | 3 + src/Notifications/data/thunks.js | 4 +- src/common/context.js | 17 ++ .../AuthenticatedUserDropdown.jsx | 7 +- src/learning-header/LearningHeader.jsx | 22 +- .../New-AuthenticatedUserDropdown.jsx | 137 ++++++++++ .../NotificationEmptySection.jsx | 42 +++ src/new-notifications/NotificationRowItem.jsx | 86 ++++++ .../NotificationSections.jsx | 136 ++++++++++ src/new-notifications/NotificationTabs.jsx | 57 ++++ src/new-notifications/context.js | 5 + .../data/__factories__/index.js | 1 + .../__factories__/notifications.factory.js | 32 +++ src/new-notifications/data/api.js | 37 +++ src/new-notifications/data/api.test.js | 147 ++++++++++ src/new-notifications/data/constants.js | 13 + src/new-notifications/data/hook.js | 174 ++++++++++++ src/new-notifications/index.jsx | 232 ++++++++++++++++ src/new-notifications/index.test.jsx | 121 +++++++++ src/new-notifications/messages.js | 66 +++++ src/new-notifications/notification.scss | 255 ++++++++++++++++++ .../notificationRowItem.test.jsx | 75 ++++++ .../notificationSections.test.jsx | 126 +++++++++ .../notificationTabs.test.jsx | 87 ++++++ src/new-notifications/test-utils.js | 32 +++ .../tours/NotificationTour.jsx | 31 +++ src/new-notifications/tours/constants.js | 19 ++ src/new-notifications/tours/data/api.js | 15 ++ src/new-notifications/tours/data/hooks.js | 74 +++++ src/new-notifications/tours/messages.js | 26 ++ src/new-notifications/utils.js | 64 +++++ 33 files changed, 2138 insertions(+), 10 deletions(-) create mode 100644 src/common/context.js create mode 100644 src/learning-header/New-AuthenticatedUserDropdown.jsx create mode 100644 src/new-notifications/NotificationEmptySection.jsx create mode 100644 src/new-notifications/NotificationRowItem.jsx create mode 100644 src/new-notifications/NotificationSections.jsx create mode 100644 src/new-notifications/NotificationTabs.jsx create mode 100644 src/new-notifications/context.js create mode 100644 src/new-notifications/data/__factories__/index.js create mode 100644 src/new-notifications/data/__factories__/notifications.factory.js create mode 100644 src/new-notifications/data/api.js create mode 100644 src/new-notifications/data/api.test.js create mode 100644 src/new-notifications/data/constants.js create mode 100644 src/new-notifications/data/hook.js create mode 100644 src/new-notifications/index.jsx create mode 100644 src/new-notifications/index.test.jsx create mode 100644 src/new-notifications/messages.js create mode 100644 src/new-notifications/notification.scss create mode 100644 src/new-notifications/notificationRowItem.test.jsx create mode 100644 src/new-notifications/notificationSections.test.jsx create mode 100644 src/new-notifications/notificationTabs.test.jsx create mode 100644 src/new-notifications/test-utils.js create mode 100644 src/new-notifications/tours/NotificationTour.jsx create mode 100644 src/new-notifications/tours/constants.js create mode 100644 src/new-notifications/tours/data/api.js create mode 100644 src/new-notifications/tours/data/hooks.js create mode 100644 src/new-notifications/tours/messages.js create mode 100644 src/new-notifications/utils.js diff --git a/src/Notifications/data/__factories__/notifications.factory.js b/src/Notifications/data/__factories__/notifications.factory.js index 043f292fe..4e3ad0d61 100644 --- a/src/Notifications/data/__factories__/notifications.factory.js +++ b/src/Notifications/data/__factories__/notifications.factory.js @@ -8,7 +8,8 @@ Factory.define('notificationsCount') grades: 10, authoring: 5, }) - .attr('showNotificationsTray', true); + .attr('showNotificationsTray', true) + .attr('isNewNotificationViewEnabled', false); Factory.define('notification') .sequence('id') diff --git a/src/Notifications/data/selectors.js b/src/Notifications/data/selectors.js index 9f31bb64a..c9fcfc1ca 100644 --- a/src/Notifications/data/selectors.js +++ b/src/Notifications/data/selectors.js @@ -12,6 +12,8 @@ export const selectSelectedAppNotificationIds = (appName) => state => state.noti export const selectShowNotificationTray = state => state.notifications.showNotificationsTray; +export const selectIsNewNotificationViewEnabled = state => state.notifications.isNewNotificationViewEnabled; + export const selectNotifications = state => state.notifications.notifications; export const selectNotificationsByIds = (appName) => createSelector( diff --git a/src/Notifications/data/slice.js b/src/Notifications/data/slice.js index 26923b790..9800a1b70 100644 --- a/src/Notifications/data/slice.js +++ b/src/Notifications/data/slice.js @@ -19,6 +19,7 @@ const initialState = { showNotificationsTray: false, pagination: {}, trayOpened: false, + isNewNotificationViewEnabled: false, }; const slice = createSlice({ name: 'notifications', @@ -54,6 +55,7 @@ const slice = createSlice({ fetchNotificationsCountSuccess: (state, { payload }) => { const { countByAppName, appIds, apps, count, showNotificationsTray, notificationExpiryDays, + isNewNotificationViewEnabled, } = payload; state.tabsCount = { count, ...countByAppName }; state.appsId = appIds; @@ -61,6 +63,7 @@ const slice = createSlice({ state.showNotificationsTray = showNotificationsTray; state.notificationStatus = RequestStatus.SUCCESSFUL; state.notificationExpiryDays = notificationExpiryDays; + state.isNewNotificationViewEnabled = isNewNotificationViewEnabled; }, markAllNotificationsAsReadSuccess: (state) => { const updatedNotifications = Object.fromEntries( diff --git a/src/Notifications/data/thunks.js b/src/Notifications/data/thunks.js index 18fdc6357..1a63e4829 100644 --- a/src/Notifications/data/thunks.js +++ b/src/Notifications/data/thunks.js @@ -16,12 +16,12 @@ import { } from './api'; const normalizeNotificationCounts = ({ - countByAppName, count, showNotificationsTray, notificationExpiryDays, + countByAppName, count, showNotificationsTray, notificationExpiryDays, isNewNotificationViewEnabled, }) => { const appIds = Object.keys(countByAppName); const apps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {}); return { - countByAppName, appIds, apps, count, showNotificationsTray, notificationExpiryDays, + countByAppName, appIds, apps, count, showNotificationsTray, notificationExpiryDays, isNewNotificationViewEnabled, }; }; diff --git a/src/common/context.js b/src/common/context.js new file mode 100644 index 000000000..77d5b2a3b --- /dev/null +++ b/src/common/context.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { RequestStatus } from '../new-notifications/data/constants'; + +export const initialState = { + notificationStatus: RequestStatus.IDLE, + notificationListStatus: RequestStatus.IDLE, + appName: 'discussion', + appsId: [], + apps: {}, + notifications: {}, + tabsCount: {}, + showNotificationsTray: false, + pagination: {}, + trayOpened: false, +}; + +export const HeaderContext = React.createContext(initialState); diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index 03e400abf..e4aa41a50 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -12,7 +12,7 @@ import { Dropdown, Badge } from '@openedx/paragon'; import messages from './messages'; import Notifications from '../Notifications'; import UserMenuItem from '../common/UserMenuItem'; -import { selectShowNotificationTray } from '../Notifications/data/selectors'; +import { selectShowNotificationTray, selectIsNewNotificationViewEnabled } from '../Notifications/data/selectors'; import { fetchAppsNotificationCount } from '../Notifications/data/thunks'; const AuthenticatedUserDropdown = (props) => { @@ -25,6 +25,7 @@ const AuthenticatedUserDropdown = (props) => { } = props; const dispatch = useDispatch(); const showNotificationsTray = useSelector(selectShowNotificationTray); + const isNewNotificationViewEnabled = useSelector(selectIsNewNotificationViewEnabled); useEffect(() => { dispatch(fetchAppsNotificationCount()); @@ -70,6 +71,10 @@ const AuthenticatedUserDropdown = (props) => { careersMenuItem = ''; } + if (isNewNotificationViewEnabled) { + return null; + } + return ( <> {intl.formatMessage(messages.help)} diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index f6f0f0e74..573a23237 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -9,6 +9,7 @@ import { AppContext, AppProvider } from '@edx/frontend-platform/react'; import classNames from 'classnames'; import AnonymousUserMenu from './AnonymousUserMenu'; import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; +import NewAuthenticatedUserDropdown from './New-AuthenticatedUserDropdown'; import messages from './messages'; import lightning from '../lightning'; import store from '../store'; @@ -89,12 +90,21 @@ const LearningHeader = ({ {courseTitle} {showUserDropdown && authenticatedUser && ( - + <> + + + + )} {showUserDropdown && !authenticatedUser && ( diff --git a/src/learning-header/New-AuthenticatedUserDropdown.jsx b/src/learning-header/New-AuthenticatedUserDropdown.jsx new file mode 100644 index 000000000..fbb98d80e --- /dev/null +++ b/src/learning-header/New-AuthenticatedUserDropdown.jsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserCircle } from '@fortawesome/free-solid-svg-icons'; + +import { getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Dropdown, Badge } from '@openedx/paragon'; + +import messages from './messages'; +import Notifications from '../new-notifications'; +import UserMenuItem from '../common/UserMenuItem'; +import { useNotification } from '../new-notifications/data/hook'; + +const AuthenticatedUserDropdown = (props) => { + const { + intl, + enterpriseLearnerPortalLink, + username, + name, + email, + } = props; + const [showTray, setShowTray] = useState(); + const [isNewNotificationView, setIsNewNotificationView] = useState(false); + const [notificationAppData, setNotificationAppData] = useState(); + const { fetchAppsNotificationCount } = useNotification(); + + const fetchNotifications = useCallback(async () => { + const data = await fetchAppsNotificationCount(); + const { showNotificationsTray, isNewNotificationViewEnabled } = data; + + setShowTray(showNotificationsTray); + setIsNewNotificationView(isNewNotificationViewEnabled); + setNotificationAppData(data); + }, [fetchAppsNotificationCount]); + + useEffect(() => { + fetchNotifications(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.location.href]); + + let dashboardMenuItem = ( + + {intl.formatMessage(messages.dashboard)} + + ); + + let careersMenuItem = ( + + {intl.formatMessage(messages.career)} + + {intl.formatMessage(messages.newAlert)} + + + ); + + const userMenuItem = (name || email) ? ( + + + + ) : null; + + if (enterpriseLearnerPortalLink && Object.keys(enterpriseLearnerPortalLink).length > 0) { + dashboardMenuItem = ( + + {enterpriseLearnerPortalLink.content} + + ); + careersMenuItem = ''; + } + + if (!isNewNotificationView) { + return null; + } + + return ( + <> + {intl.formatMessage(messages.help)} + {showTray && } + + + + + + {userMenuItem} + {dashboardMenuItem} + {careersMenuItem} + + {intl.formatMessage(messages.profile)} + + + {intl.formatMessage(messages.account)} + + {!enterpriseLearnerPortalLink && getConfig().ORDER_HISTORY_URL && ( + // Users should only see Order History if they do not have an available + // learner portal, because an available learner portal currently means + // that they access content via B2B Subscriptions, in which context an "order" + // is not relevant. + + {intl.formatMessage(messages.orderHistory)} + + )} + + {intl.formatMessage(messages.signOut)} + + + + + ); +}; + +AuthenticatedUserDropdown.propTypes = { + enterpriseLearnerPortalLink: PropTypes.string, + intl: intlShape.isRequired, + username: PropTypes.string.isRequired, + name: PropTypes.string, + email: PropTypes.string, +}; + +AuthenticatedUserDropdown.defaultProps = { + enterpriseLearnerPortalLink: '', + name: '', + email: '', +}; + +export default injectIntl(AuthenticatedUserDropdown); diff --git a/src/new-notifications/NotificationEmptySection.jsx b/src/new-notifications/NotificationEmptySection.jsx new file mode 100644 index 000000000..9dd908471 --- /dev/null +++ b/src/new-notifications/NotificationEmptySection.jsx @@ -0,0 +1,42 @@ +import React, { useContext } from 'react'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton } from '@openedx/paragon'; +import { NotificationsNone } from '@openedx/paragon/icons'; + +import NotificationContext from './context'; +import messages from './messages'; + +const EmptyNotifications = () => { + const intl = useIntl(); + const { popoverHeaderRef, notificationRef } = useContext(NotificationContext); + + return ( +
+ +
+ {intl.formatMessage(messages.noNotificationsYetMessage)} +
+
+ + {intl.formatMessage(messages.noNotificationHelpMessage)} + +
+
+ ); +}; + +export default React.memo(EmptyNotifications); diff --git a/src/new-notifications/NotificationRowItem.jsx b/src/new-notifications/NotificationRowItem.jsx new file mode 100644 index 000000000..2fe77eda7 --- /dev/null +++ b/src/new-notifications/NotificationRowItem.jsx @@ -0,0 +1,86 @@ +import React, { useCallback, useContext } from 'react'; +import PropTypes from 'prop-types'; + +import * as timeago from 'timeago.js'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@openedx/paragon'; + +import messages from './messages'; +import timeLocale from '../common/time-locale'; +import { getIconByType } from './utils'; +import { useNotification } from './data/hook'; +import { HeaderContext } from '../common/context'; + +const NotificationRowItem = ({ + id, type, contentUrl, content, courseName, createdAt, lastRead, +}) => { + timeago.register('time-locale', timeLocale); + const intl = useIntl(); + const { markNotificationsAsRead } = useNotification(); + const { updateNotificationData } = useContext(HeaderContext); + + const handleMarkAsRead = useCallback(async () => { + if (!lastRead) { + const data = await markNotificationsAsRead(id); + updateNotificationData(data); + } + }, [id, lastRead, markNotificationsAsRead, updateNotificationData]); + + const { icon: iconComponent, class: iconClass } = getIconByType(type); + + return ( + + +
+
+
+ +
+ + {courseName} + + {intl.formatMessage(messages.fullStop)} + {timeago.format(createdAt, 'time-locale')} + + +
+
+ {!lastRead && ( +
+ +
+ )} +
+
+
+ ); +}; + +NotificationRowItem.propTypes = { + id: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + contentUrl: PropTypes.string.isRequired, + content: PropTypes.node.isRequired, + courseName: PropTypes.string.isRequired, + createdAt: PropTypes.string.isRequired, + lastRead: PropTypes.string.isRequired, +}; + +export default React.memo(NotificationRowItem); diff --git a/src/new-notifications/NotificationSections.jsx b/src/new-notifications/NotificationSections.jsx new file mode 100644 index 000000000..07710abc1 --- /dev/null +++ b/src/new-notifications/NotificationSections.jsx @@ -0,0 +1,136 @@ +import React, { useCallback, useContext, useMemo } from 'react'; + +import classNames from 'classnames'; +import isEmpty from 'lodash/isEmpty'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Icon, Spinner } from '@openedx/paragon'; +import { AutoAwesome, CheckCircleLightOutline } from '@openedx/paragon/icons'; + +import { RequestStatus } from './data/constants'; +import NotificationContext from './context'; +import messages from './messages'; +import NotificationEmptySection from './NotificationEmptySection'; +import NotificationRowItem from './NotificationRowItem'; +import { splitNotificationsByTime } from './utils'; +import { HeaderContext } from '../common/context'; +import { useNotification } from './data/hook'; + +const NotificationSections = () => { + const intl = useIntl(); + const { + appName, notificationListStatus, pagination, + notificationExpiryDays, appsId, updateNotificationData, + } = useContext(HeaderContext); + const { getNotifications, markAllNotificationsAsRead, fetchNotificationList } = useNotification(); + const notificationList = getNotifications(); + const { hasMorePages, currentPage } = pagination || {}; + const { popoverHeaderRef, notificationRef } = useContext(NotificationContext); + const { today = [], earlier = [] } = useMemo( + () => splitNotificationsByTime(notificationList), + [notificationList], + ); + + const handleMarkAllAsRead = useCallback(async () => { + const data = await markAllNotificationsAsRead(appName); + updateNotificationData(data); + }, [appName, markAllNotificationsAsRead, updateNotificationData]); + + const loadMoreNotifications = useCallback(async () => { + const data = await fetchNotificationList(appName, currentPage + 1); + updateNotificationData(data); + }, [fetchNotificationList, appName, currentPage, updateNotificationData]); + + const renderNotificationSection = (section, items) => { + if (isEmpty(items)) { return null; } + + return ( +
+
+ + {section === 'today' && intl.formatMessage(messages.notificationTodayHeading)} + {section === 'earlier' && intl.formatMessage(messages.notificationEarlierHeading)} + + {notificationList?.length > 0 && (section === 'earlier' ? today.length === 0 : true) && ( + + )} +
+ {items.map((notification) => ( + + ))} +
+ ); + }; + + const shouldRenderEmptyNotifications = notificationList?.length === 0 + && notificationListStatus === RequestStatus.SUCCESSFUL + && notificationRef?.current + && popoverHeaderRef?.current; + + return ( +
1, + 'pb-3.5': appsId.length > 0, + })} + data-testid="notification-tray-section" + > + {renderNotificationSection('today', today)} + {renderNotificationSection('earlier', earlier)} + {(hasMorePages === undefined || hasMorePages) && notificationListStatus === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : (hasMorePages && notificationListStatus === RequestStatus.SUCCESSFUL && ( + + ) + )} + { + notificationList.length > 0 && !hasMorePages && notificationListStatus === RequestStatus.SUCCESSFUL && ( +
+ +
+ {intl.formatMessage(messages.allRecentNotificationsMessage)} +
+
+ + + {intl.formatMessage(messages.expiredNotificationsDeleteMessage, { days: notificationExpiryDays })} + +
+
+ ) + } + + {shouldRenderEmptyNotifications && } +
+ ); +}; + +export default React.memo(NotificationSections); diff --git a/src/new-notifications/NotificationTabs.jsx b/src/new-notifications/NotificationTabs.jsx new file mode 100644 index 000000000..b07555a11 --- /dev/null +++ b/src/new-notifications/NotificationTabs.jsx @@ -0,0 +1,57 @@ +import React, { useEffect, useContext } from 'react'; + +import { Tab, Tabs } from '@openedx/paragon'; + +import NotificationSections from './NotificationSections'; +import { useFeedbackWrapper } from './utils'; +import { HeaderContext } from '../common/context'; +import { useNotification } from './data/hook'; + +const NotificationTabs = () => { + useFeedbackWrapper(); + const { + appName, handleActiveTab, tabsCount, appsId, updateNotificationData, + } = useContext(HeaderContext); + const { fetchNotificationList, markNotificationsAsSeen } = useNotification(); + + useEffect(() => { + const fetchNotifications = async () => { + const data = await fetchNotificationList(appName); + updateNotificationData(data); + if (tabsCount[appName]) { + await markNotificationsAsSeen(appName); + } + }; + + fetchNotifications(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appName]); + + return ( + appsId.length > 1 + ? ( + + {appsId?.map((app) => ( + + {appName === app && } + + ))} + + ) + : + ); +}; + +export default React.memo(NotificationTabs); diff --git a/src/new-notifications/context.js b/src/new-notifications/context.js new file mode 100644 index 000000000..7b0b4eef6 --- /dev/null +++ b/src/new-notifications/context.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const NotificationContext = React.createContext({ popoverHeaderRef: null, notificationRef: null }); + +export default NotificationContext; diff --git a/src/new-notifications/data/__factories__/index.js b/src/new-notifications/data/__factories__/index.js new file mode 100644 index 000000000..cdf7f0b4f --- /dev/null +++ b/src/new-notifications/data/__factories__/index.js @@ -0,0 +1 @@ +import './notifications.factory'; diff --git a/src/new-notifications/data/__factories__/notifications.factory.js b/src/new-notifications/data/__factories__/notifications.factory.js new file mode 100644 index 000000000..6c9e3a313 --- /dev/null +++ b/src/new-notifications/data/__factories__/notifications.factory.js @@ -0,0 +1,32 @@ +import { Factory } from 'rosie'; + +Factory.define('notificationsCount') + .attr('count', 45) + .attr('countByAppName', { + reminders: 10, + discussion: 20, + grades: 10, + authoring: 5, + }) + .attr('showNotificationsTray', true) + .attr('isNewNotificationViewEnabled', true); + +Factory.define('notification') + .sequence('id') + .attr('type', 'post') + .sequence('content', ['id'], (idx, notificationId) => `

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

`) + .attr('course_name', 'Supply Chain Analytics') + .sequence('content_url', (idx) => `https://example.com/${idx}`) + .attr('last_read', null) + .attr('last_seen', null) + .sequence('created', ['createdDate'], (idx, date) => date); + +Factory.define('notificationsList') + .attr('next', null) + .attr('previous', null) + .attr('count', null, 2) + .attr('num_pages', null, 1) + .attr('current_page', null, 1) + .attr('start', null, 0) + .attr('results', ['results'], (results) => results || Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })); diff --git a/src/new-notifications/data/api.js b/src/new-notifications/data/api.js new file mode 100644 index 000000000..8d2c77776 --- /dev/null +++ b/src/new-notifications/data/api.js @@ -0,0 +1,37 @@ +import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export const getNotificationsCountApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/count/`; +export const getNotificationsListApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`; +export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-seen/${appName}/`; +export const markNotificationAsReadApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/read/`; + +export async function getNotificationsList(appName, page, pageSize, trayOpened) { + const params = snakeCaseObject({ + appName, page, pageSize, trayOpened, + }); + const { data } = await getAuthenticatedHttpClient().get(getNotificationsListApiUrl(), { params }); + return data; +} + +export async function getNotificationCounts() { + const { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl()); + return data; +} + +export async function markNotificationSeen(appName) { + const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`); + return data; +} + +export async function markAllNotificationRead(appName) { + const params = snakeCaseObject({ appName }); + const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params); + return data; +} + +export async function markNotificationRead(notificationId) { + const params = snakeCaseObject({ notificationId }); + const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params); + return { data, id: notificationId }; +} diff --git a/src/new-notifications/data/api.test.js b/src/new-notifications/data/api.test.js new file mode 100644 index 000000000..a905f6c2f --- /dev/null +++ b/src/new-notifications/data/api.test.js @@ -0,0 +1,147 @@ +import MockAdapter from 'axios-mock-adapter'; +import { Factory } from 'rosie'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; + +import { + getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl, + getNotificationCounts, getNotificationsList, markNotificationSeen, markAllNotificationRead, markNotificationRead, +} from './api'; + +import './__factories__'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsListApiUrl(); +const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion'); +const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl(); + +let axiosMock = null; + +describe('Notifications API', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: '123abc', + username: 'testuser', + administrator: false, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + it('Successfully get notification counts for different tabs.', async () => { + axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount'))); + + const { count, countByAppName } = await getNotificationCounts(); + + expect(count).toEqual(45); + expect(countByAppName.reminders).toEqual(10); + expect(countByAppName.discussion).toEqual(20); + expect(countByAppName.grades).toEqual(10); + expect(countByAppName.authoring).toEqual(5); + }); + + it.each([ + { statusCode: 404, message: 'Failed to get notification counts.' }, + { statusCode: 403, message: 'Denied to get notification counts.' }, + ])('%s for notification counts API.', async ({ statusCode, message }) => { + axiosMock.onGet(notificationCountsApiUrl).reply(statusCode, { message }); + try { + await getNotificationCounts(); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully get notifications.', async () => { + axiosMock.onGet(notificationsApiUrl).reply(200, (Factory.build('notificationsList'))); + + const notifications = await getNotificationsList('discussion', 1); + + expect(notifications.results).toHaveLength(2); + }); + + it.each([ + { statusCode: 404, message: 'Failed to get notifications.' }, + { statusCode: 403, message: 'Denied to get notifications.' }, + ])('%s for notification API.', async ({ statusCode, message }) => { + axiosMock.onGet(notificationsApiUrl).reply(statusCode, { message }); + try { + await getNotificationsList('discussion', 1); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully marked all notifications as seen for selected app.', async () => { + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200, { message: 'Notifications marked seen.' }); + + const { message } = await markNotificationSeen('discussion'); + + expect(message).toEqual('Notifications marked seen.'); + }); + + it.each([ + { statusCode: 404, message: 'Failed to mark all notifications as seen for selected app.' }, + { statusCode: 403, message: 'Denied to mark all notifications as seen for selected app.' }, + ])('%s for notification mark as seen API.', async ({ statusCode, message }) => { + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(statusCode, { message }); + try { + await markNotificationSeen('discussion'); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully marked all notifications as read for selected app.', async () => { + axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' }); + + const { message } = await markAllNotificationRead('discussion'); + + expect(message).toEqual('Notifications marked read.'); + }); + + it.each([ + { statusCode: 404, message: 'Failed to mark all notifications as read for selected app.' }, + { statusCode: 403, message: 'Denied to mark all notifications as read for selected app.' }, + ])('%s for notification mark all as read API.', async ({ statusCode, message }) => { + axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message }); + try { + await markAllNotificationRead('discussion'); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); + + it('Successfully marked notification as read.', async () => { + axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notification marked read.' }); + + const { data } = await markNotificationRead(1); + + expect(data.message).toEqual('Notification marked read.'); + }); + + it.each([ + { statusCode: 404, message: 'Failed to mark notification as read.' }, + { statusCode: 403, message: 'Denied to mark notification as read.' }, + ])('%s for notification mark as read API.', async ({ statusCode, message }) => { + axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message }); + try { + await markAllNotificationRead(1); + } catch (error) { + expect(error.response.status).toEqual(statusCode); + expect(error.response.data.message).toEqual(message); + } + }); +}); diff --git a/src/new-notifications/data/constants.js b/src/new-notifications/data/constants.js new file mode 100644 index 000000000..5b6485b6a --- /dev/null +++ b/src/new-notifications/data/constants.js @@ -0,0 +1,13 @@ +/* eslint-disable import/prefer-default-export */ +/** + * Enum for request status. + * @readonly + * @enum {string} + */ +export const RequestStatus = { + IDLE: 'idle', + IN_PROGRESS: 'in-progress', + SUCCESSFUL: 'successful', + FAILED: 'failed', + DENIED: 'denied', +}; diff --git a/src/new-notifications/data/hook.js b/src/new-notifications/data/hook.js new file mode 100644 index 000000000..15ab6b5ea --- /dev/null +++ b/src/new-notifications/data/hook.js @@ -0,0 +1,174 @@ +import { useContext } from 'react'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { breakpoints, useWindowSize } from '@openedx/paragon'; +import { RequestStatus } from './constants'; +import { HeaderContext } from '../../common/context'; +import { + getNotificationsList, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead, +} from './api'; + +export function useIsOnMediumScreen() { + const windowSize = useWindowSize(); + return breakpoints.large.maxWidth > windowSize.width && windowSize.width >= breakpoints.medium.minWidth; +} + +export function useIsOnLargeScreen() { + const windowSize = useWindowSize(); + return windowSize.width >= breakpoints.extraLarge.minWidth; +} + +export function useNotification() { + const { + appName, apps, tabsCount, notifications, + } = useContext(HeaderContext); + + const normalizeNotificationCounts = ({ + countByAppName, count, showNotificationsTray, notificationExpiryDays, isNewNotificationViewEnabled, + }) => { + const appIds = Object.keys(countByAppName); + const notificationApps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {}); + + return { + countByAppName, + appIds, + notificationApps, + count, + showNotificationsTray, + notificationExpiryDays, + isNewNotificationViewEnabled, + }; + }; + + const normalizeNotifications = (data) => { + const newNotificationIds = data.results.map(notification => notification.id.toString()); + const notificationsKeyValuePair = data.results.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {}); + const pagination = { + numPages: data.numPages, + currentPage: data.currentPage, + hasMorePages: !!data.next, + }; + + return { + newNotificationIds, notificationsKeyValuePair, pagination, + }; + }; + + const getNotifications = () => { + try { + const notificationIds = apps[appName] || []; + + return notificationIds.map((notificationId) => notifications[notificationId]) || []; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }; + + const fetchAppsNotificationCount = async () => { + try { + const data = await getNotificationCounts(); + const normalisedData = normalizeNotificationCounts(camelCaseObject(data)); + + const { + countByAppName, appIds, notificationApps, count, showNotificationsTray, notificationExpiryDays, + isNewNotificationViewEnabled, + } = normalisedData; + + return { + tabsCount: { count, ...countByAppName }, + appsId: appIds, + apps: notificationApps, + showNotificationsTray, + notificationStatus: RequestStatus.SUCCESSFUL, + notificationExpiryDays, + isNewNotificationViewEnabled, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }; + + const fetchNotificationList = async (app, page = 1, pageSize = 10, trayOpened = true) => { + try { + const data = await getNotificationsList(app, page, pageSize, trayOpened); + const normalizedData = normalizeNotifications((camelCaseObject(data))); + + const { + newNotificationIds, notificationsKeyValuePair, pagination, + } = normalizedData; + + const existingNotificationIds = apps[appName]; + const { count } = tabsCount; + + return { + apps: { + ...apps, + [appName]: Array.from(new Set([...existingNotificationIds, ...newNotificationIds])), + }, + notifications: { ...notifications, ...notificationsKeyValuePair }, + tabsCount: { + ...tabsCount, + count: count - tabsCount[appName], + [appName]: 0, + }, + notificationListStatus: RequestStatus.SUCCESSFUL, + pagination, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }; + + const markNotificationsAsSeen = async (app) => { + try { + await markNotificationSeen(app); + + return { notificationStatus: RequestStatus.SUCCESSFUL }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }; + + const markAllNotificationsAsRead = async (app) => { + try { + await markAllNotificationRead(app); + const updatedNotifications = Object.fromEntries( + Object.entries(notifications).map(([key, notification]) => [ + key, { ...notification, lastRead: new Date().toISOString() }, + ]), + ); + + return { + notifications: updatedNotifications, + notificationStatus: RequestStatus.SUCCESSFUL, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }; + + const markNotificationsAsRead = async (notificationId) => { + try { + const data = camelCaseObject(await markNotificationRead(notificationId)); + + const date = new Date().toISOString(); + const notificationList = { ...notifications }; + notificationList[data.id] = { ...notifications[data.id], lastRead: date }; + + return { + notifications: notificationList, + notificationStatus: RequestStatus.SUCCESSFUL, + }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }; + + return { + fetchAppsNotificationCount, + fetchNotificationList, + getNotifications, + markNotificationsAsSeen, + markAllNotificationsAsRead, + markNotificationsAsRead, + }; +} diff --git a/src/new-notifications/index.jsx b/src/new-notifications/index.jsx new file mode 100644 index 000000000..00edf91ee --- /dev/null +++ b/src/new-notifications/index.jsx @@ -0,0 +1,232 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { + useCallback, useEffect, useMemo, + useRef, useState, +} from 'react'; + +import classNames from 'classnames'; +import PropTypes from 'prop-types'; + +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Bubble, Button, Hyperlink, Icon, IconButton, OverlayTrigger, Popover, +} from '@openedx/paragon'; +import { NotificationsNone, Settings } from '@openedx/paragon/icons'; +import { RequestStatus } from './data/constants'; + +import { useIsOnLargeScreen, useIsOnMediumScreen } from './data/hook'; +import NotificationTour from './tours/NotificationTour'; +import NotificationContext from './context'; +import messages from './messages'; +import NotificationTabs from './NotificationTabs'; +import { HeaderContext } from '../common/context'; + +import './notification.scss'; + +const Notifications = ({ notificationAppData, showLeftMargin }) => { + const intl = useIntl(); + const popoverRef = useRef(null); + const headerRef = useRef(null); + const buttonRef = useRef(null); + const [enableNotificationTray, setEnableNotificationTray] = useState(false); + const [appName, setAppName] = useState('discussion'); + const [isHeaderVisible, setIsHeaderVisible] = useState(true); + const [notificationData, setNotificationData] = useState({}); + const [tabsCount, setTabsCount] = useState(notificationAppData?.tabsCount); + const isOnMediumScreen = useIsOnMediumScreen(); + const isOnLargeScreen = useIsOnLargeScreen(); + + const toggleNotificationTray = useCallback(() => { + setEnableNotificationTray(prevState => !prevState); + }, [enableNotificationTray]); + + const handleClickOutsideNotificationTray = useCallback((event) => { + if (!popoverRef.current?.contains(event.target) && !buttonRef.current?.contains(event.target)) { + setEnableNotificationTray(false); + } + }, []); + + useEffect(() => { + setTabsCount(notificationAppData.tabsCount); + setNotificationData(prevData => ({ + ...prevData, + ...notificationAppData, + })); + }, [notificationAppData]); + + useEffect(() => { + const handleScroll = () => { + setIsHeaderVisible(window.scrollY < 100); + }; + + window.addEventListener('scroll', handleScroll); + document.addEventListener('mousedown', handleClickOutsideNotificationTray); + + return () => { + document.removeEventListener('mousedown', handleClickOutsideNotificationTray); + window.removeEventListener('scroll', handleScroll); + setAppName('discussion'); + }; + }, []); + + const enableFeedback = useCallback(() => { + window.usabilla_live('click'); + }, []); + + const notificationRefs = useMemo( + () => ({ popoverHeaderRef: headerRef, notificationRef: popoverRef }), + [headerRef, popoverRef], + ); + + const handleActiveTab = useCallback((selectedAppName) => { + setAppName(selectedAppName); + setNotificationData(prevData => ({ + ...prevData, + ...{ notificationListStatus: RequestStatus.IDLE }, + })); + }, []); + + const updateNotificationData = useCallback((data) => { + setNotificationData(prevData => ({ + ...prevData, + ...data, + })); + if (data.tabsCount) { + setTabsCount(data?.tabsCount); + } + }, []); + + const headerContextValue = useMemo(() => ({ + enableNotificationTray, + appName, + handleActiveTab, + updateNotificationData, + ...notificationData, + })); + + return ( + + +
+
+ + {intl.formatMessage(messages.notificationTitle)} + + + + +
+ + + + + + {getConfig().NOTIFICATION_FEEDBACK_URL && ( + + )} +
+ + )} + > +
+ + {tabsCount?.count > 0 && ( + = 10, + 'notification-badge-rounded': tabsCount.count < 10, + })} + onClick={toggleNotificationTray} + > + {tabsCount.count >= 100 ?
99

+

+ : tabsCount.count} +
+ )} +
+
+ +
+ ); +}; + +Notifications.propTypes = { + showLeftMargin: PropTypes.bool, + notificationAppData: { + apps: PropTypes.object.isRequired, + appsId: PropTypes.arrayOf(PropTypes.string).isRequired, // Array of strings + isNewNotificationViewEnabled: PropTypes.bool.isRequired, // Boolean + notificationExpiryDays: PropTypes.number.isRequired, // Number + notificationStatus: PropTypes.string.isRequired, // String + showNotificationsTray: PropTypes.bool.isRequired, // Boolean + tabsCount: PropTypes.shape({ + count: PropTypes.number.isRequired, // Assuming count is a number + }).isRequired, + }, +}; + +Notifications.defaultProps = { + showLeftMargin: true, + notificationAppData: { + apps: { }, + tabsCount: { }, + appsId: [], + isNewNotificationViewEnabled: false, + notificationExpiryDays: 0, + notificationStatus: '', + showNotificationsTray: false, + }, +}; + +export default Notifications; diff --git a/src/new-notifications/index.test.jsx b/src/new-notifications/index.test.jsx new file mode 100644 index 000000000..9aea4f5c7 --- /dev/null +++ b/src/new-notifications/index.test.jsx @@ -0,0 +1,121 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; + +import MockAdapter from 'axios-mock-adapter'; +import { Context as ResponsiveContext } from 'react-responsive'; +import { Factory } from 'rosie'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import * as notificationApi from './data/api'; + +import './data/__factories__'; + +const notificationCountsApiUrl = notificationApi.getNotificationsCountApiUrl(); + +let axiosMock; + +async function renderComponent() { + render( + + + + + , + ); +} + +describe('Notification test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + async function setupMockNotificationCountResponse(count = 45, showNotificationsTray = true) { + axiosMock.onGet(notificationCountsApiUrl) + .reply(200, (Factory.build('notificationsCount', { count, showNotificationsTray }))); + } + + it('Successfully showed bell icon and unseen count on it if unseen count is greater then 0.', async () => { + await setupMockNotificationCountResponse(); + await renderComponent(); + + await waitFor(() => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + const notificationCount = screen.queryByTestId('notification-count'); + + expect(bellIcon).toBeInTheDocument(); + expect(notificationCount).toBeInTheDocument(); + expect(screen.queryByText(45)).toBeInTheDocument(); + }); + }); + + it('Successfully showed bell icon and hide unseen count tag when unseen count is zero.', async () => { + await setupMockNotificationCountResponse(0); + await renderComponent(); + + await waitFor(() => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + const notificationCount = screen.queryByTestId('notification-count'); + + expect(bellIcon).toBeInTheDocument(); + expect(notificationCount).not.toBeInTheDocument(); + }); + }); + + it('Successfully hides bell icon when showNotificationsTray is false.', async () => { + await setupMockNotificationCountResponse(45, false); + await renderComponent(); + + await waitFor(() => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + + expect(bellIcon).not.toBeInTheDocument(); + }); + }); + + it('Successfully viewed setting icon and show/hide notification tray by clicking on the bell icon .', async () => { + await setupMockNotificationCountResponse(); + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + + await act(async () => { fireEvent.click(bellIcon); }); + expect(screen.queryByTestId('notification-tray')).toBeInTheDocument(); + expect(screen.queryByTestId('setting-icon')).toBeInTheDocument(); + + await act(async () => { fireEvent.click(bellIcon); }); + await waitFor(() => expect(screen.queryByTestId('notification-tray')).not.toBeInTheDocument()); + }); + }); + + it.each(['/', '/notification', '/my-post'])( + 'Successfully call getNotificationCounts on URL %s change', + async (url) => { + const getNotificationCountsSpy = jest.spyOn(notificationApi, 'getNotificationCounts').mockReturnValue(() => true); + renderComponent(url); + + expect(getNotificationCountsSpy).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/src/new-notifications/messages.js b/src/new-notifications/messages.js new file mode 100644 index 000000000..18dd733f4 --- /dev/null +++ b/src/new-notifications/messages.js @@ -0,0 +1,66 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + notificationTitle: { + id: 'notification.title', + defaultMessage: 'Notifications', + description: 'Notifications', + }, + notificationTodayHeading: { + id: 'notification.today.heading', + defaultMessage: 'Last 24 hours', + description: 'Today Notifications', + }, + notificationEarlierHeading: { + id: 'notification.earlier.heading', + defaultMessage: 'Earlier', + description: 'Earlier Notifications', + }, + notificationMarkAsRead: { + id: 'notification.mark.as.read', + defaultMessage: 'Mark all as read', + description: 'Mark all Notifications as read', + }, + fullStop: { + id: 'notification.fullStop', + defaultMessage: '•', + description: 'Fullstop shown to users to indicate who edited a post.', + }, + loadMoreNotifications: { + id: 'notification.load.more.notifications', + defaultMessage: 'Load more notifications', + description: 'Load more button to load more notifications', + }, + feedback: { + id: 'notification.feedback', + defaultMessage: 'Feedback', + description: 'text for feedback widget', + }, + allRecentNotificationsMessage: { + id: 'notification.recent.all.message', + defaultMessage: 'That’s all of your recent notifications!', + description: 'Message visible when all notifications are loaded', + }, + expiredNotificationsDeleteMessage: { + id: 'notification.expired.delete.message', + defaultMessage: 'Notifications are automatically cleared after {days} days', + description: 'Message showing that expired notifications will be deleted', + }, + noNotificationsYetMessage: { + id: 'notification.no.message', + defaultMessage: 'No notifications yet', + description: 'Message visible when there is no notification in the notification tray', + }, + noNotificationHelpMessage: { + id: 'notification.no.help.message', + defaultMessage: 'When you receive notifications they’ll show up here', + description: 'Message showing that when you receive notifications they’ll show up here', + }, + notificationBellIconAltMessage: { + id: 'notification.bell.icon.alt.message', + defaultMessage: 'Notification bell icon', + description: 'Alt message for notification bell icon', + }, +}); + +export default messages; diff --git a/src/new-notifications/notification.scss b/src/new-notifications/notification.scss new file mode 100644 index 000000000..b183c18d1 --- /dev/null +++ b/src/new-notifications/notification.scss @@ -0,0 +1,255 @@ +.zIndex-2 { + z-index: 2 !important; +} + +#pgn__checkpoint { + z-index: 1 !important; +} + +.cursor-pointer { + cursor: pointer; +} + +#notificationIcon { + .plus-icon { + margin-top: -0.5px; + } + + .notification-button { + width: 36px !important; + height: 36px !important; + + &:focus, + &:active { + box-shadow: inset 0 0 0 2px #00262B !important; + } + + &:focus, + &:active, + &:hover { + background-color: #F2F0EF !important; + } + + span:first-child { + margin: 0px !important; + } + } + + .notification-lg-bell-icon { + width: 56px !important; + height: 56px !important; + + span:first-child { + width: 32px !important; + height: 32px !important; + } + } + + .notification-button.btn-icon-light-active { + background-color: #F2F0EF !important; + } + + .notification-badge { + position: absolute; + border: 2px solid #FFFFFF; + font-size: 11px !important; + font-weight: 500 !important; + font-variant-numeric: lining-nums tabular-nums; + background: var(--text-on-light-brand-500, #D23228) !important; + line-height: 20px !important; + padding: 4px !important; + padding-left: 3px !important; + margin-left: -21px; + } + + .notification-badge-unrounded { + margin-top: 4px !important; + min-width: 23px !important; + height: 16px; + min-height: 16px !important; + border-radius: 54px !important; + } + + .notification-badge-rounded { + border-radius: 50%; + margin-top: 1px; + height: 20px; + min-height: 20px !important; + width: 20px !important; + min-width: 20px !important; + } +} + +#notificationTray { + filter: none; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), 0px 2px 8px rgba(0, 0, 0, 0.15); + margin-left: 5px !important; + margin-top: 7px !important; + + .popover-header { + top: 0px; + } + + .tabs { + top: 0px; + } + + &.medium-screen { + min-width: 34.313rem; + } + + &.large-screen { + min-width: 34.313rem; + } + + &.popover-margin-top { + margin-top: -60px !important; + } + + &.height-100vh { + height: 100vh; + } + + &.height-91vh { + height: 91vh; + } + + .dropdown-toggle::after { + display: none; + } + + .expandable { + position: relative !important; + margin-left: 4px; + padding: 2px 5px; + border-radius: 10rem; + font-size: 9px; + } + + .dropdown-toggle { + font-size: 14px; + padding-top: 0px !important; + padding-bottom: 12px !important; + + div { + min-height: 6px !important; + min-width: 6px !important; + } + } + + .dropdown-item { + font-size: 14px; + font-weight: 500; + } + + .notification-content { + .notification-item-content { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; + + p { + margin-bottom: 0px; + } + + b { + color: #00262B; + } + } + + .unread { + height: 10px; + width: 10px; + } + + .nav-tabs .nav-link { + &:focus { + &::before { + border: none !important; + } + } + } + } + + .notification-feedback-widget { + right: -37px !important; + position: fixed !important; + transform: rotate(270deg) !important; + top: 50% !important; + } + + .height-inherit { + height: inherit; + } + + .line-height-normal { + line-height: normal; + } + + .notification-end-title { + font-weight: 500 !important; + color: #00262B !important; + margin-top: 20px !important; + } + + .notification-icon { + height: 23.33px !important; + width: 23.33px !important; + z-index: 1; + } + + .icon-size-56 { + width: 56px !important; + height: 56px !important; + } + + .icon-size-20 { + width: 20px !important; + height: 20px !important; + } + + .line-height-24 { + line-height: 24px; + } + + .line-height-20 { + line-height: 20px; + } + + .line-height-10 { + line-height: 10px !important; + } + + .py-10px { + padding-top: 10px !important; + padding-bottom: 10px !important; + } + + .pb-14px { + padding-bottom: 14px !important; + } + + .font-size-18 { + font-size: 18px !important; + } + + .font-size-12 { + font-size: 12px !important; + } + + .font-size-14 { + font-size: 14px !important; + } + + .font-size-22 { + font-size: 22px !important; + } + + .content { + strong { + color: #00262B !important; + font-weight: 500 !important; + } + } +} diff --git a/src/new-notifications/notificationRowItem.test.jsx b/src/new-notifications/notificationRowItem.test.jsx new file mode 100644 index 000000000..67b5ae0a0 --- /dev/null +++ b/src/new-notifications/notificationRowItem.test.jsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, + waitFor, +} from '@testing-library/react'; +import { Context as ResponsiveContext } from 'react-responsive'; +import { Factory } from 'rosie'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import mockNotificationsResponse from './test-utils'; + +import './data/__factories__'; + +async function renderComponent() { + render( + + + + + , + ); +} + +describe('Notification row item test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + Factory.resetAll(); + + await mockNotificationsResponse(); + }); + + it( + 'Successfully viewed notification icon, notification context, unread , course name and notification time.', + async () => { + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + + expect(screen.queryByTestId('notification-icon-1')).toBeInTheDocument(); + expect(screen.queryByTestId('notification-content-1')).toBeInTheDocument(); + expect(screen.queryByTestId('notification-course-1')).toBeInTheDocument(); + expect(screen.queryByTestId('notification-created-date-1')).toBeInTheDocument(); + expect(screen.queryByTestId('unread-notification-1')).toBeInTheDocument(); + }); + }, + ); + + it('Successfully marked notification as read.', async () => { + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + + const notification = screen.queryByTestId('notification-1'); + await act(async () => { fireEvent.click(notification); }); + + expect(screen.queryByTestId('unread-notification-1')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/new-notifications/notificationSections.test.jsx b/src/new-notifications/notificationSections.test.jsx new file mode 100644 index 000000000..4e79ea9a3 --- /dev/null +++ b/src/new-notifications/notificationSections.test.jsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, waitFor, within, +} from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import { Context as ResponsiveContext } from 'react-responsive'; +import { Factory } from 'rosie'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import { getNotificationsListApiUrl, getNotificationsCountApiUrl } from './data/api'; +import mockNotificationsResponse from './test-utils'; +import './data/__factories__'; +import { getDiscussionTourUrl } from './tours/data/api'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsListApiUrl(); +const notificationsTourApiUrl = getDiscussionTourUrl(); + +let axiosMock; + +async function renderComponent() { + render( + + + + + , + ); +} + +describe('Notification sections test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + Factory.resetAll(); + }); + + it('Successfully viewed last 24 hours and earlier section along with mark all as read label.', async () => { + await mockNotificationsResponse(); + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + const notificationTraySection = screen.queryByTestId('notification-tray-section'); + + expect(within(notificationTraySection).queryByText('Last 24 hours')).toBeInTheDocument(); + expect(within(notificationTraySection).queryByText('Earlier')).toBeInTheDocument(); + expect(within(notificationTraySection).queryByText('Mark all as read')).toBeInTheDocument(); + }); + }); + + it('Successfully marked all notifications as read, removing the unread status.', async () => { + await mockNotificationsResponse(); + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + const markAllReadButton = screen.queryByTestId('mark-all-read'); + + expect(screen.queryByTestId('unread-notification-1')).toBeInTheDocument(); + await act(async () => { fireEvent.click(markAllReadButton); }); + }); + + await waitFor(async () => { + expect(screen.queryByTestId('unread-notification-1')).not.toBeInTheDocument(); + }); + }); + + it('Successfully load more notifications by clicking on load more notification button.', async () => { + await mockNotificationsResponse(10, 2); + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + + const loadMoreButton = screen.queryByTestId('load-more-notifications'); + await act(async () => { fireEvent.click(loadMoreButton); }); + }); + + await waitFor(() => { + expect(screen.queryAllByTestId('notification-contents')).toHaveLength(12); + }); + }); + + it('Successfully showed No notification yet message when the notification tray is empty.', async () => { + const notificationCountsMock = { + show_notifications_tray: true, + count: 0, + count_by_app_name: { + discussion: 0, + }, + isNewNotificationViewEnabled: true, + }; + + axiosMock.onGet(notificationCountsApiUrl).reply(200, notificationCountsMock); + axiosMock.onGet(notificationsApiUrl).reply(200, { results: [] }); + axiosMock.onGet(notificationsTourApiUrl).reply(200, []); + + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + }); + + await waitFor(() => { + expect(screen.queryByTestId('notifications-empty-list')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/new-notifications/notificationTabs.test.jsx b/src/new-notifications/notificationTabs.test.jsx new file mode 100644 index 000000000..13f2b1c75 --- /dev/null +++ b/src/new-notifications/notificationTabs.test.jsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import { + act, fireEvent, render, screen, waitFor, within, +} from '@testing-library/react'; +import { Context as ResponsiveContext } from 'react-responsive'; +import { Factory } from 'rosie'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import mockNotificationsResponse from './test-utils'; + +import './data/__factories__'; + +async function renderComponent() { + render( + + + + + , + ); +} + +describe('Notification Tabs test cases.', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: '123abc', + username: 'testuser', + administrator: false, + roles: [], + }, + }); + + Factory.resetAll(); + + await mockNotificationsResponse(); + }); + + it('Successfully displayed with default discussion tab selected under notification tabs .', async () => { + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + + const tabs = screen.queryAllByRole('tab'); + const selectedTab = tabs.find(tab => tab.getAttribute('aria-selected') === 'true'); + + expect(tabs.length).toEqual(5); + expect(within(selectedTab).queryByText('discussion')).toBeInTheDocument(); + }); + }); + + it('Successfully showed unseen counts for unselected tabs.', async () => { + await renderComponent(); + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + + const tabs = screen.getAllByRole('tab'); + + expect(within(tabs[0]).queryByRole('status')).toBeInTheDocument(); + }); + }); + + it('Successfully selected reminder tab.', async () => { + await renderComponent(); + + await waitFor(async () => { + const bellIcon = screen.queryByTestId('notification-bell-icon'); + await act(async () => { fireEvent.click(bellIcon); }); + const notificationTab = screen.getAllByRole('tab'); + let selectedTab = screen.queryByTestId('notification-tab-reminders'); + + expect(selectedTab).not.toHaveClass('active'); + + await act(async () => { fireEvent.click(notificationTab[0], { dataset: { rbEventKey: 'reminders' } }); }); + selectedTab = screen.queryByTestId('notification-tab-reminders'); + + expect(selectedTab).toHaveClass('active'); + }); + }); +}); diff --git a/src/new-notifications/test-utils.js b/src/new-notifications/test-utils.js new file mode 100644 index 000000000..655c7440c --- /dev/null +++ b/src/new-notifications/test-utils.js @@ -0,0 +1,32 @@ +import MockAdapter from 'axios-mock-adapter'; +import { Factory } from 'rosie'; + +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { + getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationsSeenApiUrl, markNotificationAsReadApiUrl, +} from './data/api'; + +import './data/__factories__'; + +const notificationCountsApiUrl = getNotificationsCountApiUrl(); +const notificationsApiUrl = getNotificationsListApiUrl(); +const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion'); +const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl(); + +export default async function mockNotificationsResponse(todaycount = 8, earlierCount = 2) { + const axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + const notifications = (Factory.buildList('notification', todaycount, null, { createdDate: new Date().toISOString() }).concat( + Factory.buildList('notification', earlierCount, null, { createdDate: '2023-06-01T00:46:11.979531Z' }), + )); + axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount'))); + axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200, { message: 'Notifications marked seen.' }); + axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' }); + + axiosMock.onGet(notificationsApiUrl).reply(200, (Factory.build('notificationsList', { + results: notifications, + num_pages: 2, + current_page: 2, + next: `${notificationsApiUrl}?app_name=discussion&page=2`, + }))); +} diff --git a/src/new-notifications/tours/NotificationTour.jsx b/src/new-notifications/tours/NotificationTour.jsx new file mode 100644 index 000000000..7657a07af --- /dev/null +++ b/src/new-notifications/tours/NotificationTour.jsx @@ -0,0 +1,31 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useEffect, useContext } from 'react'; +import isEmpty from 'lodash/isEmpty'; +import { ProductTour } from '@openedx/paragon'; +import { useNotificationTour } from './data/hooks'; +import { HeaderContext } from '../../common/context'; + +const NotificationTour = () => { + const { useTourConfiguration, fetchNotificationTours } = useNotificationTour(); + const config = useTourConfiguration(); + const { updateNotificationData } = useContext(HeaderContext); + + useEffect(() => { + const fetchTourData = async () => { + const data = await fetchNotificationTours(); + updateNotificationData(data); + }; + + fetchTourData(); + }, []); + + return ( + !isEmpty(config) && ( + + ) + ); +}; + +export default NotificationTour; diff --git a/src/new-notifications/tours/constants.js b/src/new-notifications/tours/constants.js new file mode 100644 index 000000000..8d7949dd4 --- /dev/null +++ b/src/new-notifications/tours/constants.js @@ -0,0 +1,19 @@ +import messages from './messages'; + +/** + * + * @param {Object} intl + * @returns {Object} tour checkpoints + */ +export default function tourCheckpoints(intl) { + return { + EXAMPLE_TOUR: [ + { + title: intl.formatMessage(messages.exampleTourTitle), + body: intl.formatMessage(messages.exampleTourBody), + target: '#example-tour-target', + placement: 'bottom', + }, + ], + }; +} diff --git a/src/new-notifications/tours/data/api.js b/src/new-notifications/tours/data/api.js new file mode 100644 index 000000000..aa8b327cd --- /dev/null +++ b/src/new-notifications/tours/data/api.js @@ -0,0 +1,15 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +// create constant for the API URL +export const getDiscussionTourUrl = () => `${getConfig().LMS_BASE_URL}/api/user_tours/discussion_tours/`; + +export async function getNotificationsTours() { + const { data } = await getAuthenticatedHttpClient().get(getDiscussionTourUrl()); + return data; +} + +export async function updateNotificationsTour(tourId) { + const { data } = await getAuthenticatedHttpClient().put(`${getDiscussionTourUrl()}${tourId}`, { show_tour: false }); + return data; +} diff --git a/src/new-notifications/tours/data/hooks.js b/src/new-notifications/tours/data/hooks.js new file mode 100644 index 000000000..4ee655ae0 --- /dev/null +++ b/src/new-notifications/tours/data/hooks.js @@ -0,0 +1,74 @@ +import { useMemo, useContext, useCallback } from 'react'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from '../messages'; +import tourCheckpoints from '../constants'; +import { getNotificationsTours, updateNotificationsTour } from './api'; +import { RequestStatus } from '../../data/constants'; +import { HeaderContext } from '../../../common/context'; + +export function camelToConstant(string) { + return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase(); +} + +export function useNotificationTour() { + const { tours, updateNotificationData } = useContext(HeaderContext); + + function normaliseTourData(data) { + return data.map(tour => ({ ...tour, enabled: true })); + } + + const fetchNotificationTours = useCallback(async () => { + try { + const data = await getNotificationsTours(); + const normalizedData = camelCaseObject(normaliseTourData(data)); + + return { tours: normalizedData, loading: RequestStatus.SUCCESSFUL }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, []); + + const updateTourShowStatus = useCallback(async (tourId) => { + try { + const data = await updateNotificationsTour(tourId); + const normalizedData = camelCaseObject(data); + const tourIndex = tours.findIndex(tour => tour.id === normalizedData.id); + tours[tourIndex] = normalizedData; + + return { tours, loading: RequestStatus.SUCCESSFUL }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [tours]); + + const handleOnOkay = useCallback(async (id) => { + const data = await updateTourShowStatus(id); + updateNotificationData(data); + }, [updateNotificationData, updateTourShowStatus]); + + const useTourConfiguration = async () => { + const intl = useIntl(); + + const toursConfig = useMemo(() => ( + tours?.map((tour) => Object.keys(tourCheckpoints(intl)).includes(tour.tourName) && ( + { + tourId: tour.tourName, + dismissButtonText: intl.formatMessage(messages.dismissButtonText), + endButtonText: intl.formatMessage(messages.endButtonText), + enabled: tour && Boolean(tour.enabled && tour.showTour), + onEnd: () => handleOnOkay(tour.id), + checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)], + } + )) + ), [intl]); + + return toursConfig; + }; + + return { + fetchNotificationTours, + updateTourShowStatus, + useTourConfiguration, + }; +} diff --git a/src/new-notifications/tours/messages.js b/src/new-notifications/tours/messages.js new file mode 100644 index 000000000..84a983524 --- /dev/null +++ b/src/new-notifications/tours/messages.js @@ -0,0 +1,26 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + dismissButtonText: { + id: 'tour.action.dismiss', + defaultMessage: 'Dismiss', + description: 'Action to dismiss current tour', + }, + endButtonText: { + id: 'tour.action.end', + defaultMessage: 'Okay', + description: 'Action to end current tour', + }, + exampleTourTitle: { + id: 'tour.example.title', + defaultMessage: 'Example Tour', + description: 'Title for example tour', + }, + exampleTourBody: { + id: 'tour.example.body', + defaultMessage: 'This is an example tour', + description: 'Body for example tour', + }, +}); + +export default messages; diff --git a/src/new-notifications/utils.js b/src/new-notifications/utils.js new file mode 100644 index 000000000..fd1d476bd --- /dev/null +++ b/src/new-notifications/utils.js @@ -0,0 +1,64 @@ +import { useEffect } from 'react'; + +import { getConfig } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; +import { + QuestionAnswerOutline, + PostOutline, + Report, + Verified, + Newspaper, +} from '@openedx/paragon/icons'; + +export const splitNotificationsByTime = (notificationList) => { + let splittedData = []; + if (notificationList.length > 0) { + const currentTime = Date.now(); + const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000); + + splittedData = notificationList.reduce( + (result, notification) => { + if (notification) { + const objectTime = new Date(notification.created).getTime(); + if (objectTime >= twentyFourHoursAgo && objectTime <= currentTime) { + result.today.push(notification); + } else { + result.earlier.push(notification); + } + } + return result; + }, + { today: [], earlier: [] }, + ); + } + const { today, earlier } = splittedData; + return { today, earlier }; +}; + +export const getIconByType = (type) => { + const iconMap = { + new_response: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + new_comment: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + new_comment_on_response: { icon: QuestionAnswerOutline, class: 'text-primary-500' }, + content_reported: { icon: Report, class: 'text-danger' }, + response_endorsed: { icon: Verified, class: 'text-primary-500' }, + response_endorsed_on_thread: { icon: Verified, class: 'text-primary-500' }, + course_update: { icon: Newspaper, class: 'text-primary-500' }, + }; + return iconMap[type] || { icon: PostOutline, class: 'text-primary-500' }; +}; + +export function useFeedbackWrapper() { + useEffect(() => { + try { + const url = getConfig().NOTIFICATION_FEEDBACK_URL; + if (url) { + // eslint-disable-next-line no-undef + window.usabilla_live = lightningjs.require('usabilla_live', getConfig().NOTIFICATION_FEEDBACK_URL); + window.usabilla_live('hide'); + } + } catch (error) { + logError('Error loading usabilla_live in notificationTray', error); + } + }, []); +}