Skip to content

Commit

Permalink
feat: added notifications UI (#407)
Browse files Browse the repository at this point in the history
* feat: added notifications UI

* chore: generated package-lock file using NPM 9

* feat: link setting icon with notification preferences page
  • Loading branch information
awais-ansari authored Jul 18, 2023
1 parent 3a9a234 commit b143955
Show file tree
Hide file tree
Showing 32 changed files with 4,072 additions and 3,848 deletions.
5,849 changes: 2,026 additions & 3,823 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,15 @@
"core-js": "3.31.1",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5",
"regenerator-runtime": "0.13.11"
"regenerator-runtime": "0.13.11",
"@reduxjs/toolkit": "1.9.5",
"axios-mock-adapter": "1.21.4",
"babel-polyfill": "6.26.0",
"classnames": "2.3.2",
"lodash": "4.17.21",
"react-redux": "7.2.9",
"rosie": "2.1.0",
"timeago.js": "4.0.2"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0",
Expand Down
79 changes: 79 additions & 0 deletions src/Notifications/NotificationRowItem.jsx
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);
96 changes: 96 additions & 0 deletions src/Notifications/NotificationSections.jsx
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);
53 changes: 53 additions & 0 deletions src/Notifications/NotificationTabs.jsx
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);
1 change: 1 addition & 0 deletions src/Notifications/data/__factories__/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './notifications.factory';
31 changes: 31 additions & 0 deletions src/Notifications/data/__factories__/notifications.factory.js
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() }));
39 changes: 39 additions & 0 deletions src/Notifications/data/api.js
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 };
}
Loading

0 comments on commit b143955

Please sign in to comment.