feat: added redux store implementation

This commit is contained in:
SundasNoreen
2023-06-05 10:14:32 +05:00
parent 7ab55175b5
commit 3276496523
14 changed files with 411 additions and 354 deletions

View File

@@ -1,58 +1,34 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable react/forbid-prop-types */
import React, { useCallback, useContext } from 'react';
import React, { useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import * as timeago from 'timeago.js';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import {
CheckCircle, HelpOutline, QuestionAnswerOutline, Verified, Report, EditOutline, ThumbUpOutline, PostOutline,
} from '@edx/paragon/icons';
import { useDispatch } from 'react-redux';
import { messages } from './messages';
import timeLocale from '../common/time-locale';
import { markNotificationsAsRead } from './data/thunks';
import { getIconByType } from './utils';
const NotificationRowItem = ({ notification }) => {
const intl = useIntl();
timeago.register('time-locale', timeLocale);
const { authenticatedUser } = useContext(AppContext);
const dispatch = useDispatch();
const getIconByType = (type) => {
const iconMap = {
post: { icon: PostOutline, class: 'text-primary-500' },
help: { icon: HelpOutline, class: 'text-primary-500' },
respond: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
comment: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
question: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
answer: { icon: CheckCircle, class: 'text-success' },
endorsed: { icon: Verified, class: 'text-primary-500' },
reported: { icon: Report, class: 'text-danger-500' },
postLiked: { icon: ThumbUpOutline, class: 'text-primary-500' },
commentLiked: { icon: ThumbUpOutline, class: 'text-primary-500' },
edited: { icon: EditOutline, class: 'text-primary-500' },
};
return iconMap[type] || null;
};
const handleRedirectToURL = useCallback(() => {
dispatch(markNotificationsAsRead(notification.id));
window.open(notification.contentUrl, '_blank');
}, [notification]);
const getContentMessageByType = useCallback(() => {
const contentMessage = {
post: messages.notificationPostedContent,
help: messages.notificationHelpedContent,
respond: (authenticatedUser && authenticatedUser.username) !== notification.author
? messages.notificationResponseOnOtherPostLabel : null,
comment: notification.targetUser
? messages.notificationCommentedOnLabel : messages.notificationCommentedOnYourPostLabel,
question: messages.notificationQuestionLabel,
answer: messages.notificationAnswerLabel,
endorsed: messages.notificationEndorsedLabel,
reported: messages.notificationReportedLabel,
postLiked: messages.notificationPostLikedLabel,
commentLiked: messages.notificationCommentLikedLabel,
edited: messages.notificationEditedLabel,
};
return contentMessage[notification.type] ? intl.formatMessage(contentMessage[notification.type]) : null;
}, [authenticatedUser, notification, intl]);
const handleMarkAllAsRead = useCallback(() => {
dispatch(markNotificationsAsRead(notification.id));
}, [notification.id]);
const iconComponent = getIconByType(notification.type);
return (
<div className="d-flex mb-2 align-items-center">
<Icon
@@ -60,36 +36,24 @@ const NotificationRowItem = ({ notification }) => {
style={{ height: '23.33px', width: '23.33px' }}
className={iconComponent && `${iconComponent.class} mr-4`}
/>
<div className="d-flex w-100" style={{ borderRadius: '100%' }}>
<div className="d-flex w-100">
<div className="d-flex align-items-center w-100">
<div className="py-2 w-100">
<span className="line-height-24 px-0 text-primary-500 mb-2 w-100 notification-item-content overflow-hidden">
{notification?.respondingUser } {' '}
<span className="text-gray-500">{getContentMessageByType()} </span>
{notification?.targetUser && (
<>
{notification.targetUser}
<span className="text-gray-500">
{(authenticatedUser && authenticatedUser.username) !== notification.author
? intl.formatMessage(messages.notificationResponseOnOtherPostLabel)
: intl.formatMessage(messages.notificationResponseOnYourPostLabel)}
</span>
</>
)}
<a className="text-primary-500" href={notification.URL}>
{' '}{notification?.notificationContent}
</a>
</span>
<div className="w-100 px-0 py-0 d-flex flex-row align-items-center">
<div className="py-2 w-100 px-0 cursor-pointer" onClick={handleRedirectToURL}>
<span
className="line-height-24 text-gray-700 mb-2 notification-item-content overflow-hidden"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: notification.content }}
/>
<div className="py-0 d-flex flex-row align-items-center">
<span className="font-size-12 text-gray-500 line-height-20">
<span>{notification?.courseName}</span>
<span className="text-light-700 px-1.5">{intl.formatMessage(messages.fullStop)}</span>
<span>{timeago.format(notification?.time, 'time-locale')}</span>
<span>{timeago.format(notification?.createdAt, 'time-locale')}</span>
</span>
</div>
</div>
{!notification.isRead && (
<div className="d-flex py-1.5 px-1.5 ml-2">
{!notification.lastRead && (
<div className="d-flex py-1.5 px-1.5 ml-2 cursor-pointer" onClick={handleMarkAllAsRead}>
<span className="bg-brand-500 rounded unread" />
</div>
)}

View File

@@ -1,60 +1,76 @@
import React from 'react';
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import React, { useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { Button } from '@edx/paragon';
import PropTypes from 'prop-types';
import { messages } from './messages';
import NotificationRowItem from './NotificationRowItem';
import { getNotifications } from './data/selectors';
import {
getSelectedAppNotificationIds, getSelectedAppName, getNotificationsByIds, getPaginationData,
} from './data/selectors';
import { splitNotificationsByTime } from './utils';
import { markAllNotificationsAsRead } from './data/thunks';
const NotificationSections = ({ handleLoadMoreNotification, loadMoreCount }) => {
const NotificationSections = ({ handleLoadMoreNotification }) => {
const intl = useIntl();
const notifications = useSelector(getNotifications());
const { TODAY, EARLIER, totalCount } = notifications || {};
const selectedAppName = useSelector(getSelectedAppName());
const notificationIds = useSelector(getSelectedAppNotificationIds(selectedAppName));
const notifications = useSelector(getNotificationsByIds(notificationIds));
const paginationData = useSelector(getPaginationData());
const { today = [], earlier = [] } = splitNotificationsByTime(notifications);
const dispatch = useDispatch();
const handleMarkAllAsRead = useCallback(() => {
dispatch(markAllNotificationsAsRead(selectedAppName));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAppName]);
return (
notifications && (
<div className="mt-4 px-4">
<div className="d-flex flex-row justify-content-between pb-2">
{TODAY && TODAY.length > 0 && (
{today.length > 0 && (
<>
<span className="text-gray-500">
{ intl.formatMessage(messages.notificationTodayHeading)}
</span>
{totalCount > 0 && (
<span className="text-info-500 line-height-24">
{today.length + earlier.length > 0 && (
<span className="text-info-500 line-height-24 cursor-pointer" onClick={handleMarkAllAsRead}>
{intl.formatMessage(messages.notificationMarkAsRead)}
</span>
)}
</>
)}
</div>
{TODAY && TODAY.map(
{today.map(
(notification) => <NotificationRowItem notification={notification} />,
)}
<div className="d-flex flex-row justify-content-between pb-2">
<span className="text-gray-500">
{EARLIER && EARLIER.length > 0
{earlier.length > 0
&& intl.formatMessage(messages.notificationEarlierHeading)}
</span>
{totalCount > 0 && TODAY && TODAY.length === 0 && (
<span className="text-info-500 line-height-24">
{intl.formatMessage(messages.notificationMarkAsRead)}
</span>
{today.length + earlier.length > 0 && today.length === 0 && (
<span className="text-info-500 line-height-24 cursor-pointer" onClick={handleMarkAllAsRead}>
{intl.formatMessage(messages.notificationMarkAsRead)}
</span>
)}
</div>
{EARLIER && EARLIER.map(
(notification) => <NotificationRowItem notification={notification} />,
)}
{loadMoreCount < totalCount && (
<Button
variant="primary"
className="w-100 bg-primary-500"
onClick={() => handleLoadMoreNotification(loadMoreCount + 10)}
>
{intl.formatMessage(messages.loadMoreNotifications)}
</Button>
)}
{earlier.map(
(notification) => <NotificationRowItem notification={notification} />,
)}
{paginationData.currentPage < paginationData.numPages && (
<Button
variant="primary"
className="w-100 bg-primary-500"
onClick={handleLoadMoreNotification}
>
{intl.formatMessage(messages.loadMoreNotifications)}
</Button>
)}
</div>
)
);
@@ -62,7 +78,6 @@ const NotificationSections = ({ handleLoadMoreNotification, loadMoreCount }) =>
NotificationSections.propTypes = {
handleLoadMoreNotification: PropTypes.func.isRequired,
loadMoreCount: PropTypes.number.isRequired,
};
export default React.memo(NotificationSections);

View File

@@ -4,46 +4,46 @@ import React, {
import { Tabs, Tab } from '@edx/paragon';
import { useSelector, useDispatch } from 'react-redux';
import NotificationSections from './NotificationSections';
import { getNotificationTotalUnseenCounts, getSelectedAppName } from './data/selectors';
import { getNotificationTabsCount, getSelectedAppName, getNotificationTabs } from './data/selectors';
import { fetchNotificationList, markNotificationsAsSeen } from './data/thunks';
import { notificationTabsOptions } from './data/constants';
const NotificationTabs = () => {
const notificationUnseenCounts = useSelector(getNotificationTotalUnseenCounts());
const selectedappName = useSelector(getSelectedAppName());
const [activeTab, setActiveTab] = useState(notificationTabsOptions[0].key);
const [loadMoreCount, setLoadMoreCount] = useState(10);
const notificationUnseenCounts = useSelector(getNotificationTabsCount());
const notificationTabs = useSelector(getNotificationTabs());
const selectedAppName = useSelector(getSelectedAppName());
const [activeTab, setActiveTab] = useState(selectedAppName);
const [page, setPage] = useState(1);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchNotificationList({
appName: activeTab, notificationCount: loadMoreCount, page, pageSize: 10,
}));
dispatch(markNotificationsAsSeen(activeTab));
}, [dispatch, activeTab, loadMoreCount, page]);
dispatch(fetchNotificationList({ appName: activeTab, page, pageSize: 10 }));
if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, page, selectedAppName]);
const handleActiveTab = useCallback((tab) => {
setActiveTab(tab);
setPage(1);
}, []);
const handleLoadMoreNotification = useCallback((count) => {
setLoadMoreCount(count);
const handleLoadMoreNotification = useCallback(() => {
setPage(page + 1);
}, [page]);
const tabArray = useMemo(() => notificationTabsOptions?.map((option) => (
const tabArray = useMemo(() => notificationTabs?.map((option) => (
<Tab
eventKey={option.key}
title={option.title}
notification={notificationUnseenCounts.countByAppName[option.key]}
tabClassName="pt-0 pb-2.5 px-2.5 d-flex flex-row align-items-center line-height-24"
eventKey={option}
title={option}
notification={notificationUnseenCounts[option]}
tabClassName="pt-0 pb-2.5 px-2.5 d-flex flex-row align-items-center line-height-24 text-capitalize"
>
{option.key === selectedappName
&& <NotificationSections handleLoadMoreNotification={handleLoadMoreNotification} loadMoreCount={loadMoreCount} />}
{option === selectedAppName && (
<NotificationSections
handleLoadMoreNotification={handleLoadMoreNotification}
/>
)}
</Tab>
)), [notificationUnseenCounts, handleLoadMoreNotification, loadMoreCount, selectedappName]);
)), [notificationUnseenCounts, handleLoadMoreNotification, selectedAppName, notificationTabs]);
// This code is used to replace More... text to More to match the UI
const buttons = document.getElementsByClassName('dropdown-toggle');

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useState, useCallback, useEffect, useRef,
} from 'react';
@@ -9,8 +10,8 @@ import { useSelector, useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import NotificationTabs from './NotificationTabs';
import { getNotificationTotalUnseenCounts, getNotificationStatus } from './data/selectors';
import { fetchNotificationsCountsList } from './data/thunks';
import { getNotificationTabsCount } from './data/selectors';
import { resetNotificationState } from './data/thunks';
import { messages } from './messages';
import { useIsOnDesktop, useIsOnXLDesktop } from './data/hook';
@@ -20,32 +21,23 @@ const Notifications = () => {
const popoverRef = useRef(null);
const buttonRef = useRef(null);
const dispatch = useDispatch();
const notificationStatus = useSelector(getNotificationStatus());
const notificationCounts = useSelector(getNotificationTotalUnseenCounts());
const notificationCounts = useSelector(getNotificationTabsCount());
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
useEffect(() => {
if (notificationStatus === 'idle') {
dispatch(fetchNotificationsCountsList());
}
}, [dispatch, notificationStatus]);
const handleNotificationTray = useCallback((value) => {
setShowNotificationTray(value);
if (!value) { dispatch(resetNotificationState()); }
}, []);
const handleClickOutside = useCallback((event) => {
if (popoverRef.current?.contains(event.target) !== true && buttonRef.current?.contains(event.target) !== true) {
setShowNotificationTray(false);
dispatch(resetNotificationState());
}
}, []);
useEffect(() => {
const handleClickOutside = (event) => {
if (
popoverRef.current
&& buttonRef.current
&& !popoverRef.current.contains(event.target)
&& !buttonRef.current.contains(event.target)
) {
setShowNotificationTray(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
@@ -61,7 +53,6 @@ const Notifications = () => {
overlay={(
<Popover
id="popover-positioned-bottom"
style={{ maxHeight: 'calc(100% - 68px)', minHeight: '1220px', minWidth: '549px' }}
className={classNames('notification-tray-container pt-4.5 pb-4.5 overflow-auto rounded-0 border-0', {
'w-100': !isOnDesktop,
'notificationbar-desktop-width': isOnDesktop && !isOnXLDesktop,
@@ -70,10 +61,7 @@ const Notifications = () => {
data-testid="notificationbar"
>
<div ref={popoverRef}>
<Popover.Title
as="h3"
className="d-flex flex-row justify-content-between py-0 mb-4 border-0 px-4"
>
<Popover.Title as="h3" className="d-flex flex-row justify-content-between py-0 mb-4 border-0 px-4">
<h2 className="text-primary-500 font-size-18 line-height-24">
{intl.formatMessage(messages.notificationTitle)}
</h2>
@@ -95,8 +83,7 @@ const Notifications = () => {
iconAs={Icon}
variant="light"
iconClassNames="text-primary-500"
className="ml-4 mr-1 my-3"
style={{ width: '36px', height: '36px' }}
className="ml-4 mr-1 my-3 notification-button"
/>
<Badge
variant="danger"

View File

@@ -1,6 +1,5 @@
import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { splitNotificationsByTime } from '../utils';
import notificationsList from './notifications.json';
@@ -8,58 +7,44 @@ export const getNotificationsCountApiUrl = () => `${getConfig().LMS_BASE_URL}/ap
export const getNotificationsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`;
export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-notifications-unseen/${appName}/`;
export async function getNotifications(appName, notificationCount, page, pageSize) {
export async function getNotifications(appName, page, pageSize) {
// const params = snakeCaseObject({ page, pageSize });
// const { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params });
const data = notificationsList.notifications;
const { data } = notificationsList;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const { today, earlier } = splitNotificationsByTime(camelCaseObject(data));
data = {
discussions: {
TODAY: today,
EARLIER: earlier,
},
reminders: {
TODAY: today,
EARLIER: earlier,
},
};
const notifications = data[appName];
const { TODAY = [], EARLIER = [] } = notifications || [];
let todayNotifications = TODAY;
let earlierNotifications = [];
let totalCount = 0;
if (TODAY && EARLIER) {
if (TODAY.length > notificationCount) {
todayNotifications = TODAY.slice(0, notificationCount);
} else {
todayNotifications = TODAY;
earlierNotifications = EARLIER.slice(0, notificationCount - TODAY.length);
}
totalCount = TODAY.length + EARLIER.length;
}
return { TODAY: todayNotifications, EARLIER: earlierNotifications, totalCount };
const notifications = data.slice(startIndex, endIndex);
return { notifications: camelCaseObject(notifications), numPages: 2, currentPage: page };
}
export async function getNotificationCounts() {
// const { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl());
const data = {
count: 40,
count: 45,
count_by_app_name: {
reminders: 10,
discussions: 20,
grades: 5,
grades: 10,
authoring: 5,
},
show_notification_tray: true,
};
return data;
return camelCaseObject(data);
}
export async function markNotificationSeen(appName) {
const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`);
// const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`);
const data = [];
return camelCaseObject(data);
}
export async function markAllNotificationRead() {
const { data } = camelCaseObject(notificationsList);
return data;
}
export async function markNotificationRead(notificationId) {
const { data } = camelCaseObject(notificationsList);
return { data, id: notificationId };
}

View File

@@ -1,32 +0,0 @@
export const notificationTabs = {
REMINDERS: 'reminders',
DISCUSSIONS: 'discussions',
GRADES: 'grades',
AUTHORING: 'authoring',
};
export const notificationTabsLabel = {
[notificationTabs.REMINDERS]: 'Reminders',
[notificationTabs.DISCUSSIONS]: 'Discussions',
[notificationTabs.GRADES]: 'Grades',
[notificationTabs.AUTHORING]: 'Authoring',
};
export const notificationTabsOptions = [
{
key: notificationTabs.REMINDERS,
title: notificationTabsLabel[notificationTabs.REMINDERS],
},
{
key: notificationTabs.DISCUSSIONS,
title: notificationTabsLabel[notificationTabs.DISCUSSIONS],
},
{
key: notificationTabs.GRADES,
title: notificationTabsLabel[notificationTabs.GRADES],
},
{
key: notificationTabs.AUTHORING,
title: notificationTabsLabel[notificationTabs.AUTHORING],
},
];

View File

@@ -5,7 +5,7 @@
"type": "post",
"content": "<p><b>SCM_Lead</b> posts <b>Hello and welcome to SC0x!</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:46:11.979531Z"
@@ -15,7 +15,7 @@
"type": "help",
"content": "<p><b>MITx_Learner</b> asked <b>What grade does a student need to get in order to pass the course and earn a certificate?</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
@@ -25,7 +25,7 @@
"type": "post",
"content": "<p><b>SCM_Lead</b> posts <b>Hello and welcome to SC0x!</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:46:11.979531Z"
@@ -35,7 +35,7 @@
"type": "respond",
"content": "<p><b>MITx_Learner</b> responded <b>Can't find linear regression in section 3 review</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
@@ -45,7 +45,7 @@
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented on <b>MITx_Expert's</b> response on a post your following <b>Can't find linear regression in section 3 review</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
@@ -55,7 +55,7 @@
"type": "question",
"content": "<p><b>MITx_Learner</b> commented <b>Examples of quadratic equations in supply chains</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
@@ -65,7 +65,7 @@
"type": "answer",
"content": "<p><b>MITx_Expert</b> answered <b>Examples of quadratic equations in supply chains</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
@@ -75,7 +75,7 @@
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented <b>Examples of quadratic equations in supply chains</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
@@ -85,7 +85,47 @@
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented on <b>MITx_Expert's</b>what grade does a student need to get in order to pass the course and earn a certificate?</b></p>",
"course_name": "Supply Chain Analytics",
"content_content_url": "",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 10,
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented on your response in <b>Convexity of f(x)=1/x , x>1</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 11,
"type": "answer",
"content": "<p><b>SCM_Leads</b> response has been marked as answer in your post <b>Quiz in section 3 - Please explain the F-Significance value</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 12,
"type": "endorsed",
"content": "<p>Your response has been endorsed in <b>Quiz in section 3 - Please explain the F-Significance value</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 13,
"type": "reported",
"content": "<p><b>MITx Learners</b> post has been reported <b>“Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"

View File

@@ -1,4 +1,9 @@
export const getNotificationStatus = () => state => state.notifications.notificationStatus;
export const getNotificationTotalUnseenCounts = () => state => state.notifications.totalUnseenCounts;
export const getNotifications = () => state => state.notifications.notifications;
export const getNotificationTabsCount = () => state => state.notifications.tabsCount;
export const getNotificationTabs = () => state => state.notifications.appsId;
export const getSelectedAppNotificationIds = (appName) => state => state.notifications.apps[appName] ?? [];
export const getNotificationTrayStatus = () => state => state.notifications.showNotificationTray;
export const getNotificationsByIds = (notificationIds) => state => Object.entries(state.notifications.notifications)
.filter(([key]) => notificationIds.includes(key)).map(([, value]) => value);
export const getSelectedAppName = () => state => state.notifications.appName;
export const getPaginationData = () => state => state.notifications.pagination;

View File

@@ -6,40 +6,24 @@ export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
// today or earlier logic will shift on component level
const initialState = {
notificationStatus: 'idle',
appName: 'reminders',
appsId: [],
apps: {},
notifications: {},
tabsCount: {},
showNotificationTray: false,
pagination: {
count: 10,
numPages: 1,
currentPage: 1,
},
};
const slice = createSlice({
name: 'notifications',
initialState: {
notificationStatus: 'idle',
appName: 'discussions',
appsId: ['reminders', 'discussions', 'grades', 'authoring'],
apps: {
reminders: ['notification_1', 'notification_2'],
discussions: ['notification_3'],
grades: ['notification_4', 'notification_5'],
authoring: ['notification_6'],
},
notifications: {
notification_1: {},
notification_2: {},
notification_3: {},
notification_4: {},
notification_5: {},
notification_6: {},
},
tabsCount: {
reminders: 0,
discussions: 0,
grades: 0,
authoring: 0,
totalCount: 0,
},
pagination: {
count: 90,
numPages: 9,
currentPage: 1,
},
},
initialState,
reducers: {
fetchNotificationDenied: (state, { payload }) => {
state.appName = payload.appName;
@@ -50,12 +34,24 @@ const slice = createSlice({
state.notificationStatus = FAILED;
},
fetchNotificationRequest: (state, { payload }) => {
if (state.appName !== payload.appName) { state.apps[payload.appName] = []; }
state.appName = payload.appName;
state.notificationStatus = LOADING;
},
fetchNotificationSuccess: (state, { payload }) => {
state.notifications = payload;
const { notifications, numPages, currentPage } = payload;
const newNotificationIds = notifications.map(notification => notification.id.toString());
const existingNotificationIds = state.apps[state.appName];
const notificationsKeyValuePair = notifications.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {});
const currentAppCount = state.tabsCount[state.appName];
state.apps[state.appName] = Array.from(new Set([...existingNotificationIds, ...newNotificationIds]));
state.notifications = { ...state.notifications, ...notificationsKeyValuePair };
state.tabsCount.count -= currentAppCount;
state.tabsCount[state.appName] = 0;
state.notificationStatus = LOADED;
state.pagination.numPages = numPages;
state.pagination.currentPage = currentPage;
},
fetchNotificationsCountDenied: (state) => {
state.notificationStatus = DENIED;
@@ -67,7 +63,11 @@ const slice = createSlice({
state.notificationStatus = LOADING;
},
fetchNotificationsCountSuccess: (state, { payload }) => {
state.tabsCount = payload;
const { countByAppName, count, showNotificationTray } = payload;
state.tabsCount = { count, ...countByAppName };
state.appsId = Object.keys(countByAppName);
state.apps = Object.fromEntries(Object.keys(countByAppName).map(key => [key, []]));
state.showNotificationTray = showNotificationTray;
state.notificationStatus = LOADED;
},
markNotificationsAsSeenRequest: (state) => {
@@ -82,6 +82,39 @@ const slice = createSlice({
markNotificationsAsSeenFailure: (state) => {
state.notificationStatus = FAILED;
},
markAllNotificationsAsReadRequest: (state) => {
state.notificationStatus = LOADING;
},
markAllNotificationsAsReadSuccess: (state) => {
const date = new Date().toISOString();
const updatedNotifications = Object.entries(state.notifications)
.filter(([key]) => state.apps[state.appName].includes(key))
.map(([, value]) => ({ ...value, lastRead: date }));
state.notifications = updatedNotifications;
state.notificationStatus = LOADED;
},
markAllNotificationsAsReadDenied: (state) => {
state.notificationStatus = DENIED;
},
markAllNotificationsAsReadFailure: (state) => {
state.notificationStatus = FAILED;
},
markNotificationsAsReadRequest: (state) => {
state.notificationStatus = LOADING;
},
markNotificationsAsReadSuccess: (state, { payload }) => {
const date = new Date().toISOString();
state.notifications[payload.id] = { ...state.notifications[payload.id], lastRead: date };
state.notificationStatus = LOADED;
},
markNotificationsAsReadDenied: (state) => {
state.notificationStatus = DENIED;
},
markNotificationsAsReadFailure: (state) => {
state.notificationStatus = FAILED;
},
resetNotificationStateRequest: () => initialState,
},
});
@@ -98,6 +131,15 @@ export const {
markNotificationsAsSeenSuccess,
markNotificationsAsSeenFailure,
markNotificationsAsSeenDenied,
markAllNotificationsAsReadDenied,
markAllNotificationsAsReadRequest,
markAllNotificationsAsReadSuccess,
markAllNotificationsAsReadFailure,
markNotificationsAsReadDenied,
markNotificationsAsReadRequest,
markNotificationsAsReadSuccess,
markNotificationsAsReadFailure,
resetNotificationStateRequest,
} = slice.actions;
export const notificationsReducer = slice.reducer;

View File

@@ -3,25 +3,44 @@ import {
fetchNotificationSuccess,
fetchNotificationRequest,
fetchNotificationFailure,
fetchNotificationDenied,
fetchNotificationsCountFailure,
fetchNotificationsCountRequest,
fetchNotificationsCountSuccess,
fetchNotificationsCountDenied,
markNotificationsAsSeenRequest,
markNotificationsAsSeenSuccess,
markNotificationsAsSeenFailure,
markNotificationsAsSeenDenied,
markNotificationsAsReadDenied,
resetNotificationStateRequest,
markAllNotificationsAsReadRequest,
markAllNotificationsAsReadSuccess,
markAllNotificationsAsReadFailure,
markAllNotificationsAsReadDenied,
markNotificationsAsReadRequest,
markNotificationsAsReadSuccess,
markNotificationsAsReadFailure,
} from './slice';
import { getNotifications, getNotificationCounts, markNotificationSeen } from './api';
import {
getNotifications, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead,
} from './api';
import { getHttpErrorStatus } from '../utils';
export const fetchNotificationList = ({
appName, notificationCount, page, pageSize,
appName, page, pageSize,
}) => (
async (dispatch) => {
try {
dispatch(fetchNotificationRequest({ appName }));
const data = await getNotifications(appName, notificationCount, page, pageSize);
const data = await getNotifications(appName, page, pageSize);
dispatch(fetchNotificationSuccess(data));
} catch (errors) {
dispatch(fetchNotificationFailure({ appName }));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchNotificationDenied(appName));
} else {
dispatch(fetchNotificationFailure(appName));
}
}
}
);
@@ -32,8 +51,44 @@ export const fetchAppsNotificationCount = () => (
dispatch(fetchNotificationsCountRequest());
const data = await getNotificationCounts();
dispatch(fetchNotificationsCountSuccess(camelCaseObject(data)));
} catch (errors) {
dispatch(fetchNotificationsCountFailure());
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchNotificationsCountDenied());
} else {
dispatch(fetchNotificationsCountFailure());
}
}
}
);
export const markAllNotificationsAsRead = (appName) => (
async (dispatch) => {
try {
dispatch(markAllNotificationsAsReadRequest({ appName }));
const data = await markAllNotificationRead(appName);
dispatch(markAllNotificationsAsReadSuccess(data));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markAllNotificationsAsReadDenied());
} else {
dispatch(markAllNotificationsAsReadFailure());
}
}
}
);
export const markNotificationsAsRead = (notificationId) => (
async (dispatch) => {
try {
dispatch(markNotificationsAsReadRequest({ notificationId }));
const data = await markNotificationRead(notificationId);
dispatch(markNotificationsAsReadSuccess(data));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markNotificationsAsReadDenied());
} else {
dispatch(markNotificationsAsReadFailure());
}
}
}
);
@@ -44,8 +99,16 @@ export const markNotificationsAsSeen = (appName) => (
dispatch(markNotificationsAsSeenRequest({ appName }));
const data = await markNotificationSeen(appName);
dispatch(markNotificationsAsSeenSuccess(data));
} catch (errors) {
dispatch(markNotificationsAsSeenFailure({ appName }));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markNotificationsAsSeenDenied());
} else {
dispatch(markNotificationsAsSeenFailure());
}
}
}
);
export const resetNotificationState = () => (
async (dispatch) => { dispatch(resetNotificationStateRequest()); }
);

View File

@@ -22,76 +22,6 @@ export const messages = defineMessages({
defaultMessage: 'Mark all as read',
description: 'Mark all Notifications as read',
},
notificationPostedContent: {
id: 'notification.posted.content',
defaultMessage: 'posted',
description: 'Display notification content for post type',
},
notificationHelpedContent: {
id: 'notification.helped.content',
defaultMessage: 'asked',
description: 'Display notification content for help type',
},
notificationRespondedLabel: {
id: 'notification.responded.label',
defaultMessage: 'responded to a post youre following',
description: 'Display notification content for respond type',
},
notificationCommentedOnLabel: {
id: 'notification.commented.on.label',
defaultMessage: 'commented on',
description: 'Display notification content for comment type',
},
notificationResponseOnOtherPostLabel: {
id: 'notification.response.on.other.post.label',
defaultMessage: 'response on a post youre following:',
description: 'Display notification content for comment type for other posts',
},
notificationQuestionLabel: {
id: 'notification.question.label',
defaultMessage: 'responded to your question',
description: 'Display notification content for question type',
},
notificationResponseOnYourPostLabel: {
id: 'notification.response.on.your.post.label',
defaultMessage: 'response to your post',
description: 'Display notification content for comment type for your post',
},
notificationCommentedOnYourPostLabel: {
id: 'notification.commented.on.your.post.label',
defaultMessage: 'commented on your response in',
description: 'Display notification content for comment type on your response',
},
notificationAnswerLabel: {
id: 'notification.answer.label',
defaultMessage: 'response has been marked as answer in your post',
description: 'Display notification content for answer type',
},
notificationEndorsedLabel: {
id: 'notification.endorsed.label',
defaultMessage: 'Your response has been endorsed in',
description: 'Display notification content for endorsed type',
},
notificationReportedLabel: {
id: 'notification.reported.label',
defaultMessage: 'post has been reported',
description: 'Display notification content for reported type',
},
notificationPostLikedLabel: {
id: 'notification.post.liked.label',
defaultMessage: 'liked your post',
description: 'Display notification content for post liked type',
},
notificationCommentLikedLabel: {
id: 'notification.comment.liked.label',
defaultMessage: 'liked your response in',
description: 'Display notification content for response liked type',
},
notificationEditedLabel: {
id: 'notification.edited.label',
defaultMessage: 'edited your post',
description: 'Display notification content for edited type',
},
fullStop: {
id: 'notification.fullStop',
defaultMessage: '•',

View File

@@ -1,24 +1,50 @@
import {
CheckCircle, HelpOutline, QuestionAnswerOutline, Verified, Report, EditOutline, ThumbUpOutline, PostOutline,
} from '@edx/paragon/icons';
/**
* Get HTTP Error status from generic error.
* @param error Generic caught error.
* @returns {number|null}
*/
export const getHttpErrorStatus = error => error?.customAttributes?.httpErrorStatus ?? error?.response?.status;
export const splitNotificationsByTime = (notificationList) => {
const currentTime = Date.now();
const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000);
const { today, earlier } = notificationList.reduce(
(result, notification) => {
const objectTime = new Date(notification.createdAt).getTime();
if (objectTime >= twentyFourHoursAgo && objectTime <= currentTime) {
result.today.push(notification);
} else {
result.earlier.push(notification);
}
return result;
},
{ today: [], earlier: [] },
);
let splittedData = [];
if (notificationList.length > 0) {
const currentTime = Date.now();
const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000);
splittedData = notificationList.reduce(
(result, notification) => {
const objectTime = new Date(notification.createdAt).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 getNotificationCount = (notificationCounts, appName) => {
const { countByAppName } = notificationCounts;
return countByAppName[appName] || 0;
export const getIconByType = (type) => {
const iconMap = {
post: { icon: PostOutline, class: 'text-primary-500' },
help: { icon: HelpOutline, class: 'text-primary-500' },
respond: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
comment: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
question: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
answer: { icon: CheckCircle, class: 'text-success' },
endorsed: { icon: Verified, class: 'text-primary-500' },
reported: { icon: Report, class: 'text-danger-500' },
postLiked: { icon: ThumbUpOutline, class: 'text-primary-500' },
commentLiked: { icon: ThumbUpOutline, class: 'text-primary-500' },
edited: { icon: EditOutline, class: 'text-primary-500' },
};
return iconMap[type] || null;
};

View File

@@ -141,6 +141,18 @@ $white: #fff;
width: 20px !important;
height: 20px !important;
}
.cursor-pointer{
cursor: pointer;
}
#popover-positioned-bottom{
max-height: calc(100% - 68px);
min-height: 1220px;
min-width: 549px;
}
.notification-button{
width: 36px;
height: 36px;
}
.notification-badge{
position: absolute;
margin-top: 18px;
@@ -173,6 +185,12 @@ $white: #fff;
}
.notification-content{
.notification-item-content{
p{
margin-bottom: 0px;
}
b{
color: #00262B;
}
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -7,11 +7,25 @@ import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import { useSelector, useDispatch } from 'react-redux';
import Notifications from '../Notifications/Notifications';
import { getNotificationTrayStatus, getNotificationStatus } from '../Notifications/data/selectors';
import { fetchAppsNotificationCount } from '../Notifications/data/thunks';
import messages from './messages';
const AuthenticatedUserDropdown = ({ intl, username }) => {
const showNotificationTray = useSelector(getNotificationTrayStatus());
const notificationStatus = useSelector(getNotificationStatus());
const dispatch = useDispatch();
useEffect(() => {
if (notificationStatus === 'idle') {
dispatch(fetchAppsNotificationCount());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notificationStatus]);
const dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
@@ -21,7 +35,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
return (
<>
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
<Notifications />
{showNotificationTray && <Notifications />}
<Dropdown className="user-dropdown ml-3">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />