From 38532c76340d81bb27f6ff75d0e02517a7ea2c59 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Wed, 2 Oct 2024 00:53:07 +0500 Subject: [PATCH 01/12] 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 043f292f..4e3ad0d6 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 9f31bb64..c9fcfc1c 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 26923b79..9800a1b7 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 18fdc635..1a63e482 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 00000000..77d5b2a3 --- /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 03e400ab..e4aa41a5 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 f6f0f0e7..573a2323 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 00000000..fbb98d80 --- /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 00000000..9dd90847 --- /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 00000000..2fe77eda --- /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 00000000..07710abc --- /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 00000000..b07555a1 --- /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 00000000..7b0b4eef --- /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 00000000..cdf7f0b4 --- /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 00000000..6c9e3a31 --- /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 00000000..8d2c7777 --- /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 00000000..a905f6c2 --- /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 00000000..5b6485b6 --- /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 00000000..15ab6b5e --- /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 00000000..00edf91e --- /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 00000000..9aea4f5c --- /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 00000000..18dd733f --- /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 00000000..b183c18d --- /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 00000000..67b5ae0a --- /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 00000000..4e79ea9a --- /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 00000000..13f2b1c7 --- /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 00000000..655c7440 --- /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 00000000..7657a07a --- /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 00000000..8d7949dd --- /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 00000000..aa8b327c --- /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 00000000..4ee655ae --- /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 00000000..84a98352 --- /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 00000000..fd1d476b --- /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); + } + }, []); +} From ee56869c41ca09c478b5fd8b33923b7b96a84e8f Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Wed, 2 Oct 2024 13:06:31 +0500 Subject: [PATCH 02/12] fix: fixed more notification show button issue --- src/new-notifications/NotificationSections.jsx | 2 +- src/new-notifications/data/hook.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/new-notifications/NotificationSections.jsx b/src/new-notifications/NotificationSections.jsx index 07710abc..88893013 100644 --- a/src/new-notifications/NotificationSections.jsx +++ b/src/new-notifications/NotificationSections.jsx @@ -97,7 +97,7 @@ const NotificationSections = () => {
- ) : (hasMorePages && notificationListStatus === RequestStatus.SUCCESSFUL && ( + ) : (hasMorePages && notificationListStatus === RequestStatus.SUCCESSFUL && notificationList.length >= 10 && ( From 1cfe64b9e2bc25892ce3d31aaad9a3722e784363 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Fri, 18 Oct 2024 23:35:02 +0500 Subject: [PATCH 06/12] fix: fixed disable next line lint issue --- package-lock.json | 7 +++++-- package.json | 4 ++-- src/new-notifications/NotificationTabs.jsx | 10 +++++++--- src/new-notifications/index.jsx | 20 +++++++++++--------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0db76d42..63c0446a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "lodash": "4.17.21", "react-redux": "7.2.9", "react-responsive": "^10.0.0", - "react-router-dom": "^6.25.1", "react-transition-group": "4.4.5", "regenerator-runtime": "0.14.1", "rosie": "2.1.1", @@ -58,7 +57,8 @@ "@openedx/paragon": "^21.11.0", "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", - "react-dom": "^16.9.0 || ^17.0.0" + "react-dom": "^16.9.0 || ^17.0.0", + "react-router-dom": "^6.25.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -3552,6 +3552,7 @@ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -13636,6 +13637,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.20.0" }, @@ -13651,6 +13653,7 @@ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.20.0", "react-router": "6.27.0" diff --git a/package.json b/package.json index 3e176bf4..292ba812 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "lodash": "4.17.21", "react-redux": "7.2.9", "react-responsive": "^10.0.0", - "react-router-dom": "^6.25.1", "react-transition-group": "4.4.5", "regenerator-runtime": "0.14.1", "rosie": "2.1.1", @@ -82,6 +81,7 @@ "@openedx/paragon": "^21.11.0", "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", - "react-dom": "^16.9.0 || ^17.0.0" + "react-dom": "^16.9.0 || ^17.0.0", + "react-router-dom": "^6.25.1" } } diff --git a/src/new-notifications/NotificationTabs.jsx b/src/new-notifications/NotificationTabs.jsx index 57900ccb..11cbb2c6 100644 --- a/src/new-notifications/NotificationTabs.jsx +++ b/src/new-notifications/NotificationTabs.jsx @@ -1,4 +1,6 @@ -import React, { useEffect, useContext, useCallback } from 'react'; +import React, { + useEffect, useContext, useCallback, useRef, +} from 'react'; import { Tab, Tabs } from '@openedx/paragon'; @@ -23,12 +25,14 @@ const NotificationTabs = () => { } }, [appName, fetchNotificationList, updateNotificationData, markNotificationsAsSeen, tabsCount]); + const fetchNotificationsRef = useRef(fetchNotificationsList); + fetchNotificationsRef.current = fetchNotificationsList; + useEffect(() => { const fetchNotifications = async () => { - await fetchNotificationsList(); + await fetchNotificationsRef.current(); }; fetchNotifications(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [appName]); return ( diff --git a/src/new-notifications/index.jsx b/src/new-notifications/index.jsx index d39e8892..af65e2a5 100644 --- a/src/new-notifications/index.jsx +++ b/src/new-notifications/index.jsx @@ -200,17 +200,19 @@ const Notifications = ({ notificationAppData, showLeftMargin }) => { 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 + notificationAppData: PropTypes.shape({ + apps: PropTypes.objectOf( + PropTypes.arrayOf(PropTypes.string), + ).isRequired, + appsId: PropTypes.arrayOf(PropTypes.string).isRequired, + isNewNotificationViewEnabled: PropTypes.bool.isRequired, + notificationExpiryDays: PropTypes.number.isRequired, + notificationStatus: PropTypes.string.isRequired, + showNotificationsTray: PropTypes.bool.isRequired, tabsCount: PropTypes.shape({ - count: PropTypes.number.isRequired, // Assuming count is a number + count: PropTypes.number.isRequired, }).isRequired, - }, + }), }; Notifications.defaultProps = { From 88a4faa73ed9382c3abdfc6a3a6bce81f5fc6f38 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Sat, 19 Oct 2024 00:20:39 +0500 Subject: [PATCH 07/12] fix: fixed UI placement issue --- src/learning-header/LearningHeader.jsx | 68 ++++++++++---- .../New-AuthenticatedUserDropdown.jsx | 90 +++++++------------ 2 files changed, 81 insertions(+), 77 deletions(-) diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index 462143c5..b398bbab 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -1,5 +1,9 @@ -import React, { useContext } from 'react'; +import React, { + useEffect, useState, useCallback, useContext, +} from 'react'; import PropTypes from 'prop-types'; +import { useLocation } from 'react-router-dom'; + import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils'; import { APP_CONFIG_INITIALIZED, getConfig, ensureConfig, subscribe, mergeConfig, @@ -13,6 +17,8 @@ import NewAuthenticatedUserDropdown from './New-AuthenticatedUserDropdown'; import messages from './messages'; import lightning from '../lightning'; import store from '../store'; +import { useNotification } from '../new-notifications/data/hook'; +import Notifications from '../new-notifications'; ensureConfig([ 'ACCOUNT_SETTINGS_URL', @@ -50,6 +56,27 @@ const LearningHeader = ({ courseOrg, courseNumber, courseTitle, intl, showUserDropdown, }) => { const { authenticatedUser } = useContext(AppContext); + const [showTray, setShowTray] = useState(); + const [isNewNotificationView, setIsNewNotificationView] = useState(false); + const [notificationAppData, setNotificationAppData] = useState(); + const { fetchAppsNotificationCount } = useNotification(); + const location = useLocation(); + + const fetchNotificationData = useCallback(async () => { + const data = await fetchAppsNotificationCount(); + const { showNotificationsTray, isNewNotificationViewEnabled } = data; + + setShowTray(showNotificationsTray); + setIsNewNotificationView(isNewNotificationViewEnabled); + setNotificationAppData(data); + }, [fetchAppsNotificationCount]); + + useEffect(() => { + const fetchNotifications = async () => { + await fetchNotificationData(); + }; + fetchNotifications(); + }, [fetchNotificationData, location.pathname]); const { enterpriseLearnerPortalLink, enterpriseCustomerBrandingConfig } = useEnterpriseConfig( authenticatedUser, @@ -91,23 +118,28 @@ const LearningHeader = ({ {courseOrg} {courseNumber} {courseTitle} - {showUserDropdown && authenticatedUser && ( - <> - - - - - )} + {showUserDropdown && authenticatedUser + && isNewNotificationView ? ( + <> + + {intl.formatMessage(messages.help)} + + {showTray && } + + + ) : ( + + )} {showUserDropdown && !authenticatedUser && ( )} diff --git a/src/learning-header/New-AuthenticatedUserDropdown.jsx b/src/learning-header/New-AuthenticatedUserDropdown.jsx index f2359994..6bfbfe0d 100644 --- a/src/learning-header/New-AuthenticatedUserDropdown.jsx +++ b/src/learning-header/New-AuthenticatedUserDropdown.jsx @@ -1,6 +1,5 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { useLocation } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUserCircle } from '@fortawesome/free-solid-svg-icons'; @@ -10,9 +9,7 @@ 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 NewAuthenticatedUserDropdown = (props) => { const { @@ -22,27 +19,6 @@ const NewAuthenticatedUserDropdown = (props) => { name, email, } = props; - const [showTray, setShowTray] = useState(); - const [isNewNotificationView, setIsNewNotificationView] = useState(false); - const [notificationAppData, setNotificationAppData] = useState(); - const { fetchAppsNotificationCount } = useNotification(); - const location = useLocation(); - - const fetchNotificationData = useCallback(async () => { - const data = await fetchAppsNotificationCount(); - const { showNotificationsTray, isNewNotificationViewEnabled } = data; - - setShowTray(showNotificationsTray); - setIsNewNotificationView(isNewNotificationViewEnabled); - setNotificationAppData(data); - }, [fetchAppsNotificationCount]); - - useEffect(() => { - const fetchNotifications = async () => { - await fetchNotificationData(); - }; - fetchNotifications(); - }, [fetchNotificationData, location.pathname]); let dashboardMenuItem = ( @@ -83,43 +59,39 @@ const NewAuthenticatedUserDropdown = (props) => { careersMenuItem = ''; } - if (!isNewNotificationView) { - return null; - } + // 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)} + + + + + + {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)} + + + ); }; From 05ce6b2c7800203d08993e8437d98ed0e9b9fb41 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Sat, 19 Oct 2024 00:49:22 +0500 Subject: [PATCH 08/12] test: fix test cases --- src/new-notifications/index.test.jsx | 22 ++++++++++++++---- .../notificationRowItem.test.jsx | 23 +++++++++++++++---- .../notificationSections.test.jsx | 22 ++++++++++++++---- .../notificationTabs.test.jsx | 23 +++++++++++++++---- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/new-notifications/index.test.jsx b/src/new-notifications/index.test.jsx index 251ceec4..57fe63d7 100644 --- a/src/new-notifications/index.test.jsx +++ b/src/new-notifications/index.test.jsx @@ -6,14 +6,14 @@ import { import { MemoryRouter } from 'react-router-dom'; 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 { AppContext } from '@edx/frontend-platform/react'; -import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import LearningHeader from '../learning-header/LearningHeader'; import * as notificationApi from './data/api'; import './data/__factories__'; @@ -21,15 +21,27 @@ import './data/__factories__'; const notificationCountsApiUrl = notificationApi.getNotificationsCountApiUrl(); let axiosMock; +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; async function renderComponent() { render( - + - + - + , ); } diff --git a/src/new-notifications/notificationRowItem.test.jsx b/src/new-notifications/notificationRowItem.test.jsx index 4530b4eb..4418f54e 100644 --- a/src/new-notifications/notificationRowItem.test.jsx +++ b/src/new-notifications/notificationRowItem.test.jsx @@ -5,25 +5,38 @@ import { waitFor, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -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 { AppContext } from '@edx/frontend-platform/react'; -import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import LearningHeader from '../learning-header/LearningHeader'; import mockNotificationsResponse from './test-utils'; import './data/__factories__'; +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; + async function renderComponent() { render( - + - + - + , ); } diff --git a/src/new-notifications/notificationSections.test.jsx b/src/new-notifications/notificationSections.test.jsx index 5ac72c32..ee011ea3 100644 --- a/src/new-notifications/notificationSections.test.jsx +++ b/src/new-notifications/notificationSections.test.jsx @@ -5,14 +5,14 @@ import { } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; 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 { AppContext } from '@edx/frontend-platform/react'; -import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import LearningHeader from '../learning-header/LearningHeader'; import { getNotificationsListApiUrl, getNotificationsCountApiUrl } from './data/api'; import mockNotificationsResponse from './test-utils'; import './data/__factories__'; @@ -23,15 +23,27 @@ const notificationsApiUrl = getNotificationsListApiUrl(); const notificationsTourApiUrl = getDiscussionTourUrl(); let axiosMock; +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; async function renderComponent() { render( - + - + - + , ); } diff --git a/src/new-notifications/notificationTabs.test.jsx b/src/new-notifications/notificationTabs.test.jsx index a23bb585..aeeec578 100644 --- a/src/new-notifications/notificationTabs.test.jsx +++ b/src/new-notifications/notificationTabs.test.jsx @@ -4,25 +4,38 @@ import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -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 { AppContext } from '@edx/frontend-platform/react'; -import AuthenticatedUserDropdown from '../learning-header/New-AuthenticatedUserDropdown'; +import LearningHeader from '../learning-header/LearningHeader'; import mockNotificationsResponse from './test-utils'; import './data/__factories__'; +const authenticatedUser = { + userId: 'abc123', + username: 'edX', + name: 'edX', + email: 'test@example.com', + roles: [], + administrator: false, +}; +const contextValue = { + authenticatedUser, + config: {}, +}; + async function renderComponent() { render( - + - + - + , ); } From f7f51e4499047e10358f845dd1da21d158665375 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Mon, 21 Oct 2024 16:40:57 +0500 Subject: [PATCH 09/12] fix: refactor useref code --- src/learning-header/New-AuthenticatedUserDropdown.jsx | 4 ---- src/new-notifications/NotificationTabs.jsx | 6 ++++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/learning-header/New-AuthenticatedUserDropdown.jsx b/src/learning-header/New-AuthenticatedUserDropdown.jsx index 6bfbfe0d..152a4a96 100644 --- a/src/learning-header/New-AuthenticatedUserDropdown.jsx +++ b/src/learning-header/New-AuthenticatedUserDropdown.jsx @@ -59,10 +59,6 @@ const NewAuthenticatedUserDropdown = (props) => { careersMenuItem = ''; } - // if (!isNewNotificationView) { - // return null; - // } - return ( diff --git a/src/new-notifications/NotificationTabs.jsx b/src/new-notifications/NotificationTabs.jsx index 11cbb2c6..1a5a88de 100644 --- a/src/new-notifications/NotificationTabs.jsx +++ b/src/new-notifications/NotificationTabs.jsx @@ -14,6 +14,7 @@ const NotificationTabs = () => { const { appName, handleActiveTab, tabsCount, appsId, updateNotificationData, } = useContext(notificationsContext); + const fetchNotificationsRef = useRef(null); const { fetchNotificationList, markNotificationsAsSeen } = useNotification(); const fetchNotificationsList = useCallback(async () => { @@ -25,8 +26,9 @@ const NotificationTabs = () => { } }, [appName, fetchNotificationList, updateNotificationData, markNotificationsAsSeen, tabsCount]); - const fetchNotificationsRef = useRef(fetchNotificationsList); - fetchNotificationsRef.current = fetchNotificationsList; + useEffect(() => { + fetchNotificationsRef.current = fetchNotificationsList; + }, [fetchNotificationsList]); useEffect(() => { const fetchNotifications = async () => { From 9532eb8fc3f70b7354908ea75ce190852c228039 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Thu, 24 Oct 2024 14:37:25 +0500 Subject: [PATCH 10/12] refactor: refactor learningHeader component --- .eslintrc.js | 6 +- package-lock.json | 2 +- package.json | 2 +- src/learning-header/AuthenticatedUser.jsx | 79 +++++++++++++++++++ .../AuthenticatedUserDropdown.jsx | 1 - src/learning-header/LearningHeader.jsx | 61 +++----------- src/new-notifications/NotificationRowItem.jsx | 1 + src/new-notifications/data/hook.js | 35 +++++++- .../notificationTabs.test.jsx | 2 +- .../tours/NotificationTour.jsx | 3 +- src/new-notifications/utils.js | 1 - 11 files changed, 132 insertions(+), 61 deletions(-) create mode 100644 src/learning-header/AuthenticatedUser.jsx diff --git a/.eslintrc.js b/.eslintrc.js index b4e44031..ee5c841b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,8 @@ // eslint-disable-next-line import/no-extraneous-dependencies const { createConfig } = require('@openedx/frontend-build'); -module.exports = createConfig('eslint'); +module.exports = createConfig('eslint', { + globals: { + lightningjs: true, + }, +}); diff --git a/package-lock.json b/package-lock.json index 63c0446a..88d9f757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", "react-dom": "^16.9.0 || ^17.0.0", - "react-router-dom": "^6.25.1" + "react-router-dom": "^6.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/package.json b/package.json index 292ba812..13977fad 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,6 @@ "prop-types": "^15.5.10", "react": "^16.9.0 || ^17.0.0", "react-dom": "^16.9.0 || ^17.0.0", - "react-router-dom": "^6.25.1" + "react-router-dom": "^6.0.0" } } diff --git a/src/learning-header/AuthenticatedUser.jsx b/src/learning-header/AuthenticatedUser.jsx new file mode 100644 index 00000000..a0f0b3fa --- /dev/null +++ b/src/learning-header/AuthenticatedUser.jsx @@ -0,0 +1,79 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { AppContext } from '@edx/frontend-platform/react'; +import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; +import NewAuthenticatedUserDropdown from './New-AuthenticatedUserDropdown'; +import messages from './messages'; +import { useNotification } from '../new-notifications/data/hook'; +import Notifications from '../new-notifications'; + +const BaseAuthenticatedUser = ({ children }) => { + const intl = useIntl(); + return ( + <> + + {intl.formatMessage(messages.help)} + + {children} + + ); +}; + +BaseAuthenticatedUser.propTypes = { + children: PropTypes.arrayOf(PropTypes.node).isRequired, +}; + +const AuthenticatedUser = ({ + showUserDropdown, + enterpriseLearnerPortalLink, +}) => { + const { authenticatedUser } = useContext(AppContext); + const { + showTray, + isNewNotificationView, + notificationAppData, + } = useNotification(); + + if (isNewNotificationView) { + return ( + + {showTray && } + {showUserDropdown && ( + + )} + + ); + } + + return ( + + {showUserDropdown && ( + + )} + + ); +}; + +AuthenticatedUser.propTypes = { + enterpriseLearnerPortalLink: PropTypes.string.isRequired, + showUserDropdown: PropTypes.bool.isRequired, +}; + +AuthenticatedUser.defaultProps = { +}; + +export default React.memo(AuthenticatedUser); diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index e4aa41a5..4581bc77 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -77,7 +77,6 @@ const AuthenticatedUserDropdown = (props) => { return ( <> - {intl.formatMessage(messages.help)} {showNotificationsTray && } diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index b398bbab..11022345 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -1,8 +1,5 @@ -import React, { - useEffect, useState, useCallback, useContext, -} from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; -import { useLocation } from 'react-router-dom'; import { useEnterpriseConfig } from '@edx/frontend-enterprise-utils'; import { @@ -12,13 +9,10 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; 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'; -import { useNotification } from '../new-notifications/data/hook'; -import Notifications from '../new-notifications'; +import AuthenticatedUser from './AuthenticatedUser'; ensureConfig([ 'ACCOUNT_SETTINGS_URL', @@ -56,27 +50,6 @@ const LearningHeader = ({ courseOrg, courseNumber, courseTitle, intl, showUserDropdown, }) => { const { authenticatedUser } = useContext(AppContext); - const [showTray, setShowTray] = useState(); - const [isNewNotificationView, setIsNewNotificationView] = useState(false); - const [notificationAppData, setNotificationAppData] = useState(); - const { fetchAppsNotificationCount } = useNotification(); - const location = useLocation(); - - const fetchNotificationData = useCallback(async () => { - const data = await fetchAppsNotificationCount(); - const { showNotificationsTray, isNewNotificationViewEnabled } = data; - - setShowTray(showNotificationsTray); - setIsNewNotificationView(isNewNotificationViewEnabled); - setNotificationAppData(data); - }, [fetchAppsNotificationCount]); - - useEffect(() => { - const fetchNotifications = async () => { - await fetchNotificationData(); - }; - fetchNotifications(); - }, [fetchNotificationData, location.pathname]); const { enterpriseLearnerPortalLink, enterpriseCustomerBrandingConfig } = useEnterpriseConfig( authenticatedUser, @@ -118,30 +91,14 @@ const LearningHeader = ({ {courseOrg} {courseNumber} {courseTitle} - {showUserDropdown && authenticatedUser - && isNewNotificationView ? ( - <> - - {intl.formatMessage(messages.help)} - - {showTray && } - - - ) : ( - - )} + {authenticatedUser && ( + + )} {showUserDropdown && !authenticatedUser && ( - + )} diff --git a/src/new-notifications/NotificationRowItem.jsx b/src/new-notifications/NotificationRowItem.jsx index 4da18d74..0bce0c30 100644 --- a/src/new-notifications/NotificationRowItem.jsx +++ b/src/new-notifications/NotificationRowItem.jsx @@ -58,6 +58,7 @@ const NotificationRowItem = ({
diff --git a/src/new-notifications/data/hook.js b/src/new-notifications/data/hook.js index 8c7cd4b4..9c8b5637 100644 --- a/src/new-notifications/data/hook.js +++ b/src/new-notifications/data/hook.js @@ -1,5 +1,11 @@ -import { useContext, useCallback } from 'react'; +import { + useContext, useCallback, useEffect, useState, +} from 'react'; +import { useLocation } from 'react-router-dom'; + import { camelCaseObject } from '@edx/frontend-platform'; +import { AppContext } from '@edx/frontend-platform/react'; + import { breakpoints, useWindowSize } from '@openedx/paragon'; import { RequestStatus } from './constants'; import { notificationsContext } from '../context/notificationsContext'; @@ -21,6 +27,11 @@ export function useNotification() { const { appName, apps, tabsCount, notifications, updateNotificationData, } = useContext(notificationsContext); + const { authenticatedUser } = useContext(AppContext); + const [showTray, setShowTray] = useState(); + const [isNewNotificationView, setIsNewNotificationView] = useState(false); + const [notificationAppData, setNotificationAppData] = useState(); + const location = useLocation(); const normalizeNotificationCounts = useCallback(({ countByAppName, ...countData }) => { const appIds = Object.keys(countByAppName); @@ -159,6 +170,25 @@ export function useNotification() { } }, [notifications]); + const fetchNotificationData = useCallback(async () => { + const data = await fetchAppsNotificationCount(); + const { showNotificationsTray, isNewNotificationViewEnabled } = data; + + setShowTray(showNotificationsTray); + setIsNewNotificationView(isNewNotificationViewEnabled); + setNotificationAppData(data); + }, [fetchAppsNotificationCount]); + + useEffect(() => { + const fetchNotifications = async () => { + await fetchNotificationData(); + }; + // Only fetch notifications when user is authenticated + if (authenticatedUser) { + fetchNotifications(); + } + }, [fetchNotificationData, authenticatedUser, location.pathname]); + return { fetchAppsNotificationCount, fetchNotificationList, @@ -166,5 +196,8 @@ export function useNotification() { markNotificationsAsSeen, markAllNotificationsAsRead, markNotificationsAsRead, + showTray, + isNewNotificationView, + notificationAppData, }; } diff --git a/src/new-notifications/notificationTabs.test.jsx b/src/new-notifications/notificationTabs.test.jsx index aeeec578..02c79aad 100644 --- a/src/new-notifications/notificationTabs.test.jsx +++ b/src/new-notifications/notificationTabs.test.jsx @@ -94,7 +94,7 @@ describe('Notification Tabs test cases.', () => { expect(selectedTab).not.toHaveClass('active'); - await act(async () => { fireEvent.click(notificationTab[0], { dataset: { rbEventKey: 'reminders' } }); }); + fireEvent.click(notificationTab[0], { dataset: { rbEventKey: 'reminders' } }); selectedTab = screen.queryByTestId('notification-tab-reminders'); expect(selectedTab).toHaveClass('active'); diff --git a/src/new-notifications/tours/NotificationTour.jsx b/src/new-notifications/tours/NotificationTour.jsx index cffb05a1..aa141497 100644 --- a/src/new-notifications/tours/NotificationTour.jsx +++ b/src/new-notifications/tours/NotificationTour.jsx @@ -1,4 +1,3 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import React, { useEffect, useContext } from 'react'; import isEmpty from 'lodash/isEmpty'; import { ProductTour } from '@openedx/paragon'; @@ -17,7 +16,7 @@ const NotificationTour = () => { }; fetchTourData(); - }, []); + }, [fetchNotificationTours, updateNotificationData]); return ( !isEmpty(config) && ( diff --git a/src/new-notifications/utils.js b/src/new-notifications/utils.js index fd1d476b..ef68d9ab 100644 --- a/src/new-notifications/utils.js +++ b/src/new-notifications/utils.js @@ -53,7 +53,6 @@ export function useFeedbackWrapper() { 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'); } From 0cc85752f26fa7d3c242bb8f83afbc55c9cf37c0 Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Thu, 24 Oct 2024 14:58:33 +0500 Subject: [PATCH 11/12] fix: fix test case of learningheader --- src/setupTest.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/setupTest.js b/src/setupTest.js index d231cd58..813df825 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -49,6 +49,7 @@ class MockLoggingService { export const authenticatedUser = { userId: 'abc123', username: 'Mock User', + name: 'edX', roles: [], administrator: false, }; @@ -70,6 +71,7 @@ export function initializeMockApp() { authenticatedUser: { userId: 'abc123', username: 'Mock User', + name: 'edX', roles: [], administrator: false, }, From 56e7b3872aed587cdccba7f23ada8be18b6b88cc Mon Sep 17 00:00:00 2001 From: sundasnoreen12 Date: Fri, 25 Oct 2024 12:38:24 +0500 Subject: [PATCH 12/12] fix: refactor changes --- src/learning-header/AuthenticatedUser.jsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/learning-header/AuthenticatedUser.jsx b/src/learning-header/AuthenticatedUser.jsx index a0f0b3fa..3c48c615 100644 --- a/src/learning-header/AuthenticatedUser.jsx +++ b/src/learning-header/AuthenticatedUser.jsx @@ -24,7 +24,7 @@ const BaseAuthenticatedUser = ({ children }) => { }; BaseAuthenticatedUser.propTypes = { - children: PropTypes.arrayOf(PropTypes.node).isRequired, + children: PropTypes.node.isRequired, }; const AuthenticatedUser = ({ @@ -73,7 +73,4 @@ AuthenticatedUser.propTypes = { showUserDropdown: PropTypes.bool.isRequired, }; -AuthenticatedUser.defaultProps = { -}; - -export default React.memo(AuthenticatedUser); +export default AuthenticatedUser;