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..9c8b5637 --- /dev/null +++ b/src/new-notifications/data/hook.js @@ -0,0 +1,203 @@ +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'; +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, 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); + const notificationApps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {}); + + return { + ...countData, + appIds, + notificationApps, + countByAppName, + }; + }, []); + + 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 = useCallback(() => { + try { + const notificationIds = apps[appName] || []; + + return notificationIds.map((notificationId) => notifications[notificationId]) || []; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, [apps, appName, notifications]); + + const fetchAppsNotificationCount = useCallback(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 }; + } + }, [normalizeNotificationCounts]); + + const fetchNotificationList = useCallback(async (app, page = 1, pageSize = 10, trayOpened = true) => { + try { + updateNotificationData({ notificationListStatus: RequestStatus.IN_PROGRESS }); + 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 }; + } + }, [appName, apps, tabsCount, notifications, updateNotificationData]); + + const markNotificationsAsSeen = useCallback(async (app) => { + try { + await markNotificationSeen(app); + + return { notificationStatus: RequestStatus.SUCCESSFUL }; + } catch (error) { + return { notificationStatus: RequestStatus.FAILED }; + } + }, []); + + const markAllNotificationsAsRead = useCallback(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 }; + } + }, [notifications]); + + const markNotificationsAsRead = useCallback(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 }; + } + }, [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, + getNotifications, + markNotificationsAsSeen, + markAllNotificationsAsRead, + markNotificationsAsRead, + showTray, + isNewNotificationView, + notificationAppData, + }; +} diff --git a/src/new-notifications/index.jsx b/src/new-notifications/index.jsx new file mode 100644 index 00000000..af65e2a5 --- /dev/null +++ b/src/new-notifications/index.jsx @@ -0,0 +1,231 @@ +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 NotificationPopoverContext from './context/notificationPopoverContext'; +import messages from './messages'; +import NotificationTabs from './NotificationTabs'; +import { notificationsContext } from './context/notificationsContext'; + +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); + }, []); + + 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'); + }; + }, [handleClickOutsideNotificationTray]); + + 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 notificationContextValue = useMemo(() => ({ + enableNotificationTray, + appName, + handleActiveTab, + updateNotificationData, + ...notificationData, + }), [enableNotificationTray, appName, handleActiveTab, updateNotificationData, notificationData]); + + return ( ++