-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: added notifications UI * chore: generated package-lock file using NPM 9 * feat: link setting icon with notification preferences page
- Loading branch information
1 parent
3a9a234
commit b143955
Showing
32 changed files
with
4,072 additions
and
3,848 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import React, { useCallback } from 'react'; | ||
import { useDispatch } from 'react-redux'; | ||
import { useIntl } from '@edx/frontend-platform/i18n'; | ||
import PropTypes from 'prop-types'; | ||
import { Icon } from '@edx/paragon'; | ||
import * as timeago from 'timeago.js'; | ||
import { getIconByType } from './utils'; | ||
import { markNotificationsAsRead } from './data/thunks'; | ||
import messages from './messages'; | ||
import timeLocale from '../common/time-locale'; | ||
|
||
const NotificationRowItem = ({ | ||
id, type, contentUrl, content, courseName, createdAt, lastRead, | ||
}) => { | ||
timeago.register('time-locale', timeLocale); | ||
const intl = useIntl(); | ||
const dispatch = useDispatch(); | ||
|
||
const handleMarkAsRead = useCallback(() => { | ||
dispatch(markNotificationsAsRead(id)); | ||
}, [dispatch, id]); | ||
|
||
const { icon: iconComponent, class: iconClass } = getIconByType(type); | ||
|
||
return ( | ||
<a | ||
target="_blank" | ||
className="d-flex mb-2 align-items-center text-decoration-none" | ||
href={contentUrl} | ||
onClick={handleMarkAsRead} | ||
data-testid={`notification-${id}`} | ||
rel="noopener noreferrer" | ||
> | ||
<Icon | ||
src={iconComponent} | ||
className={`${iconClass} mr-4 notification-icon`} | ||
data-testid={`notification-icon-${id}`} | ||
/> | ||
<div className="d-flex w-100" data-testid="notification-contents"> | ||
<div className="d-flex align-items-center w-100"> | ||
<div className="py-10px w-100 px-0 cursor-pointer"> | ||
<span | ||
className="line-height-24 text-gray-700 mb-2 notification-item-content overflow-hidden content" | ||
// eslint-disable-next-line react/no-danger | ||
dangerouslySetInnerHTML={{ __html: content }} | ||
data-testid={`notification-content-${id}`} | ||
/> | ||
<div className="py-0 d-flex"> | ||
<span className="font-size-12 text-gray-500 line-height-20"> | ||
<span data-testid={`notification-course-${id}`}>{courseName} | ||
</span> | ||
<span className="text-light-700 px-1.5">{intl.formatMessage(messages.fullStop)}</span> | ||
<span data-testid={`notification-created-date-${id}`}> {timeago.format(createdAt, 'time-locale')} | ||
</span> | ||
</span> | ||
</div> | ||
</div> | ||
{!lastRead && ( | ||
<div className="d-flex py-1.5 px-1.5 ml-2 cursor-pointer"> | ||
<span className="bg-brand-500 rounded unread" data-testid={`unread-notification-${id}`} /> | ||
</div> | ||
)} | ||
</div> | ||
</div> | ||
</a> | ||
); | ||
}; | ||
|
||
NotificationRowItem.propTypes = { | ||
id: PropTypes.string.isRequired, | ||
type: PropTypes.string.isRequired, | ||
contentUrl: PropTypes.string.isRequired, | ||
content: PropTypes.node.isRequired, | ||
courseName: PropTypes.string.isRequired, | ||
createdAt: PropTypes.string.isRequired, | ||
lastRead: PropTypes.string.isRequired, | ||
}; | ||
|
||
export default React.memo(NotificationRowItem); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import React, { useCallback, useMemo } from 'react'; | ||
import { Button, Spinner } from '@edx/paragon'; | ||
import { useDispatch, useSelector } from 'react-redux'; | ||
import { useIntl } from '@edx/frontend-platform/i18n'; | ||
import isEmpty from 'lodash/isEmpty'; | ||
import messages from './messages'; | ||
import NotificationRowItem from './NotificationRowItem'; | ||
import { markAllNotificationsAsRead } from './data/thunks'; | ||
import { | ||
selectNotificationsByIds, selectPaginationData, selectSelectedAppName, selectNotificationStatus, | ||
} from './data/selectors'; | ||
import { splitNotificationsByTime } from './utils'; | ||
import { updatePaginationRequest, RequestStatus } from './data/slice'; | ||
|
||
const NotificationSections = () => { | ||
const intl = useIntl(); | ||
const dispatch = useDispatch(); | ||
const selectedAppName = useSelector(selectSelectedAppName()); | ||
const notificationRequestStatus = useSelector(selectNotificationStatus()); | ||
const notifications = useSelector(selectNotificationsByIds(selectedAppName)); | ||
const { hasMorePages } = useSelector(selectPaginationData()); | ||
const { today = [], earlier = [] } = useMemo( | ||
() => splitNotificationsByTime(notifications), | ||
[notifications], | ||
); | ||
|
||
const handleMarkAllAsRead = useCallback(() => { | ||
dispatch(markAllNotificationsAsRead(selectedAppName)); | ||
}, [dispatch, selectedAppName]); | ||
|
||
const updatePagination = useCallback(() => { | ||
dispatch(updatePaginationRequest()); | ||
}, [dispatch]); | ||
|
||
const renderNotificationSection = (section, items) => { | ||
if (isEmpty(items)) { return null; } | ||
|
||
return ( | ||
<div className="pb-2"> | ||
<div className="d-flex justify-content-between align-items-center py-10px mb-2"> | ||
<span className="text-gray-500 line-height-10"> | ||
{section === 'today' && intl.formatMessage(messages.notificationTodayHeading)} | ||
{section === 'earlier' && intl.formatMessage(messages.notificationEarlierHeading)} | ||
</span> | ||
{notifications?.length > 0 && (section === 'earlier' ? today.length === 0 : true) && ( | ||
<Button | ||
variant="link" | ||
className="text-info-500 font-size-14 line-height-10 text-decoration-none p-0 border-0" | ||
onClick={handleMarkAllAsRead} | ||
data-testid="mark-all-read" | ||
> | ||
{intl.formatMessage(messages.notificationMarkAsRead)} | ||
</Button> | ||
)} | ||
</div> | ||
{items.map((notification) => ( | ||
<NotificationRowItem | ||
key={notification.id} | ||
id={notification.id} | ||
type={notification.type} | ||
contentUrl={notification.contentUrl} | ||
content={notification.content} | ||
courseName={notification.contentContext?.courseName || ''} | ||
createdAt={notification.createdAt} | ||
lastRead={notification.lastRead} | ||
/> | ||
))} | ||
</div> | ||
); | ||
}; | ||
|
||
return ( | ||
<div className="mt-4 px-4" data-testid="notification-tray-section"> | ||
{renderNotificationSection('today', today)} | ||
{renderNotificationSection('earlier', earlier)} | ||
{hasMorePages && notificationRequestStatus === RequestStatus.IN_PROGRESS ? ( | ||
<div className="d-flex justify-content-center p-4"> | ||
<Spinner animation="border" variant="primary" size="lg" /> | ||
</div> | ||
) : (hasMorePages && notificationRequestStatus === RequestStatus.SUCCESSFUL | ||
&& ( | ||
<Button | ||
variant="primary" | ||
className="w-100 bg-primary-500" | ||
onClick={updatePagination} | ||
data-testid="load-more-notifications" | ||
> | ||
{intl.formatMessage(messages.loadMoreNotifications)} | ||
</Button> | ||
) | ||
)} | ||
</div> | ||
); | ||
}; | ||
|
||
export default React.memo(NotificationSections); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
/* eslint-disable react-hooks/exhaustive-deps */ | ||
import React, { useCallback, useEffect, useMemo } from 'react'; | ||
import { useDispatch, useSelector } from 'react-redux'; | ||
import { Tab, Tabs } from '@edx/paragon'; | ||
import NotificationSections from './NotificationSections'; | ||
import { fetchNotificationList, markNotificationsAsSeen } from './data/thunks'; | ||
import { | ||
selectNotificationTabs, selectNotificationTabsCount, selectPaginationData, selectSelectedAppName, | ||
} from './data/selectors'; | ||
import { updateAppNameRequest } from './data/slice'; | ||
|
||
const NotificationTabs = () => { | ||
const dispatch = useDispatch(); | ||
const selectedAppName = useSelector(selectSelectedAppName()); | ||
const notificationUnseenCounts = useSelector(selectNotificationTabsCount()); | ||
const notificationTabs = useSelector(selectNotificationTabs()); | ||
const { currentPage } = useSelector(selectPaginationData()); | ||
|
||
useEffect(() => { | ||
dispatch(fetchNotificationList({ appName: selectedAppName, page: currentPage })); | ||
if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); } | ||
}, [currentPage, selectedAppName]); | ||
|
||
const handleActiveTab = useCallback((appName) => { | ||
dispatch(updateAppNameRequest({ appName })); | ||
}, []); | ||
|
||
const tabArray = useMemo(() => notificationTabs?.map((appName) => ( | ||
<Tab | ||
key={appName} | ||
eventKey={appName} | ||
title={appName} | ||
notification={notificationUnseenCounts[appName]} | ||
tabClassName="pt-0 pb-10px px-2.5 d-flex border-top-0 mb-0 align-items-center line-height-24 text-capitalize" | ||
data-testid={`notification-tab-${appName}`} | ||
> | ||
{appName === selectedAppName && (<NotificationSections />)} | ||
</Tab> | ||
)), [notificationUnseenCounts, selectedAppName, notificationTabs]); | ||
|
||
return ( | ||
<Tabs | ||
variant="tabs" | ||
defaultActiveKey={selectedAppName} | ||
onSelect={handleActiveTab} | ||
className="px-2.5 text-primary-500" | ||
> | ||
{tabArray} | ||
</Tabs> | ||
); | ||
}; | ||
|
||
export default React.memo(NotificationTabs); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
import './notifications.factory'; |
31 changes: 31 additions & 0 deletions
31
src/Notifications/data/__factories__/notifications.factory.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { Factory } from 'rosie'; | ||
|
||
Factory.define('notificationsCount') | ||
.attr('count', 45) | ||
.attr('countByAppName', { | ||
reminders: 10, | ||
discussion: 20, | ||
grades: 10, | ||
authoring: 5, | ||
}) | ||
.attr('showNotificationsTray', true); | ||
|
||
Factory.define('notification') | ||
.sequence('id') | ||
.attr('type', 'post') | ||
.sequence('content', ['id'], (idx, notificationId) => `<p><strong>User ${idx}</strong> posts <strong>Hello and welcome to SC0x | ||
${notificationId}!</strong></p>`) | ||
.attr('course_name', 'Supply Chain Analytics') | ||
.sequence('content_url', (idx) => `https://example.com/${idx}`) | ||
.attr('last_read', null) | ||
.attr('last_seen', null) | ||
.sequence('created_at', ['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() })); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
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) { | ||
const params = snakeCaseObject({ appName, page }); | ||
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 }; | ||
} |
Oops, something went wrong.