Compare commits

...

32 Commits

Author SHA1 Message Date
renovate[bot]
3dd41030d3 fix(deps): update dependency @edx/paragon to v20.45.5 2023-07-24 06:29:20 +00:00
Awais Ansari
27228f093d Merge pull request #370 from openedx/Ayesha/INF-951
fix: clicking on notification redirecting to correct URL
2023-07-14 13:04:49 +05:00
Muhammad Adeel Tajamul
83f3241e26 fix: course name was not visible in notification (#371) 2023-07-14 08:53:02 +05:00
ayeshoali
e153aefc13 fix: clicking on notification redirecting to correct URL 2023-07-13 15:48:35 +05:00
Bilal Qamar
936c8714b7 fix: updated frontend-build to resolve word-wrap ReDoS vulnerability (#368) 2023-07-11 17:31:29 +05:00
renovate[bot]
6b18f28933 chore(deps): update dependency jest to v29.6.1 2023-07-10 10:41:00 +00:00
ayesha waris
0ce451cfd2 feat: integrated notifications tray with backend apis (#363)
* feat: integrated notifications tray with backend apis

* test: updates test cases

* refactor: loader added and resolves minor nits

* test: fixes test cases related to pagination

* refactor: moved pagination to normalised data
2023-07-10 13:18:44 +05:00
renovate[bot]
42e831a693 chore(deps): update dependency @edx/frontend-build to v12.8.60 2023-07-10 07:38:50 +00:00
Jenkins
9af7eaf587 chore(i18n): update translations 2023-07-09 16:30:57 -04:00
Bilal Qamar
3fbbde1044 fix: updated frontend-build to bump eslint version (#364) 2023-07-06 18:14:00 +05:00
Bilal Qamar
c3f0282be4 feat: update react & react-dom to v17 (#346)
* feat: update react & react-dom to v17

* build: update paragon version

* build: update lock file

* refactor: updated frontend-platform

---------

Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-07-05 12:04:53 +05:00
sundasnoreen12
ceb9c13542 Merge pull request #360 from openedx/sundas/INF-917
test: added test cases for UI components
2023-07-03 23:20:05 -07:00
SundasNoreen
7229bff3ff refactor: added mock funtion for notification count api 2023-07-03 16:30:03 +05:00
renovate[bot]
0695d4f400 fix(deps): update font awesome to v6.4.0 2023-07-03 08:13:31 +00:00
SundasNoreen
de8783c708 refactor: fixed review points 2023-06-27 16:28:58 +05:00
renovate[bot]
e2540bc3a0 fix(deps): update dependency @edx/paragon to v20.45.0 2023-06-26 19:55:23 +00:00
renovate[bot]
ffb5a765e2 chore(deps): update dependency @edx/reactifex to v2.2.0 2023-06-26 14:58:49 +00:00
renovate[bot]
952e543217 fix(deps): update dependency axios-mock-adapter to v1.21.5 2023-06-26 11:48:04 +00:00
SundasNoreen
0e6a272f2b test: added test cases for UI components 2023-06-26 13:39:09 +05:00
renovate[bot]
45a1da9f5e chore(deps): update dependency @edx/frontend-build to v12.8.57 2023-06-26 06:43:40 +00:00
sundasnoreen12
022515d1d2 Merge pull request #355 from openedx/sundas/INF-903
feat: binded show notification tray status with the backend api
2023-06-20 01:59:04 -07:00
ayeshoali
2d737aae7f refactor: fixed data updation in redux 2023-06-20 13:31:15 +05:00
SundasNoreen
4c4db14eac feat: binded show notification tray status with the backend api 2023-06-19 18:00:06 +05:00
sundasnoreen12
911cea6a0e Merge pull request #350 from openedx/sundas/INF-878
test: added redux, selector and api cases
2023-06-19 05:10:57 -07:00
SundasNoreen
a52ddfd9bd refactor: changed api url 2023-06-19 16:58:32 +05:00
SundasNoreen
8175ba897a test: added failed and denied test cases of redux 2023-06-19 16:04:55 +05:00
renovate[bot]
cfda72b2e2 chore(deps): update dependency @testing-library/dom to v9.3.1 2023-06-19 10:46:30 +00:00
SundasNoreen
4483a734bc refactor: fixed issues of review 2023-06-19 15:33:47 +05:00
renovate[bot]
db1903cdce chore(deps): update dependency @edx/frontend-build to v12.8.54 2023-06-19 08:12:52 +00:00
Jenkins
71851b13a6 chore(i18n): update translations 2023-06-18 16:30:53 -04:00
SundasNoreen
6efa31092d test: added redux, selector and api cases 2023-06-15 17:27:11 +05:00
SundasNoreen
c3541a3d79 test: added notification redux test cases 2023-06-15 13:30:38 +05:00
35 changed files with 3756 additions and 5362 deletions

7896
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,41 +35,44 @@
"devDependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "12.8.51",
"@edx/frontend-platform": "4.5.1",
"@edx/frontend-build": "12.8.61",
"@edx/frontend-platform": "4.6.0",
"@edx/reactifex": "^2.1.1",
"@testing-library/dom": "9.3.0",
"@testing-library/dom": "9.3.1",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "10.4.9",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"husky": "8.0.3",
"jest": "29.5.0",
"jest": "29.6.1",
"jest-chain": "1.1.6",
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.9",
"react-test-renderer": "16.14.0",
"react-router-dom": "5.3.4",
"react-test-renderer": "17.0.2",
"redux": "4.2.1",
"redux-saga": "1.2.3"
},
"dependencies": {
"@edx/paragon": "20.44.0",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-brands-svg-icons": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@edx/paragon": "20.45.5",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "1.9.5",
"axios-mock-adapter": "1.21.5",
"babel-polyfill": "6.26.0",
"classnames": "2.3.2",
"lodash": "4.17.21",
"react-redux": "7.2.9",
"react-responsive": "8.2.0",
"react-router-dom": "5.3.4",
"react-transition-group": "4.4.5",
"timeago.js": "4.0.2",
"react-router-dom": "5.3.4"
"rosie": "2.1.0",
"timeago.js": "4.0.2"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0",

View File

@@ -3,7 +3,6 @@ import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Icon } from '@edx/paragon';
import { Link } from 'react-router-dom';
import * as timeago from 'timeago.js';
import { getIconByType } from './utils';
import { markNotificationsAsRead } from './data/thunks';
@@ -24,37 +23,46 @@ const NotificationRowItem = ({
const { icon: iconComponent, class: iconClass } = getIconByType(type);
return (
<Link
<a
target="_blank"
className="d-flex mb-2 align-items-center text-decoration-none"
to={contentUrl}
href={contentUrl}
onClick={handleMarkAsRead}
data-testid={`notification-${id}`}
rel="noopener noreferrer"
>
<Icon src={iconComponent} className={`${iconClass} mr-4 notification-icon`} />
<div className="d-flex w-100">
<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>{courseName}</span>
<span data-testid={`notification-course-${id}`}>{courseName}
</span>
<span className="text-light-700 px-1.5">{intl.formatMessage(messages.fullStop)}</span>
<span>{timeago.format(createdAt, 'time-locale')}</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" />
<span className="bg-brand-500 rounded unread" data-testid={`unread-notification-${id}`} />
</div>
)}
</div>
</div>
</Link>
</a>
);
};

View File

@@ -1,21 +1,24 @@
import React, { useCallback, useMemo } from 'react';
import { Button } from '@edx/paragon';
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 } from './data/selectors';
import {
selectNotificationsByIds, selectPaginationData, selectSelectedAppName, selectNotificationStatus,
} from './data/selectors';
import { splitNotificationsByTime } from './utils';
import { updatePaginationRequest } from './data/slice';
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 { currentPage, numPages } = useSelector(selectPaginationData());
const { hasMorePages } = useSelector(selectPaginationData());
const { today = [], earlier = [] } = useMemo(
() => splitNotificationsByTime(notifications),
[notifications],
@@ -44,6 +47,7 @@ const NotificationSections = () => {
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>
@@ -56,7 +60,7 @@ const NotificationSections = () => {
type={notification.type}
contentUrl={notification.contentUrl}
content={notification.content}
courseName={notification.courseName}
courseName={notification.contentContext?.courseName || ''}
createdAt={notification.createdAt}
lastRead={notification.lastRead}
/>
@@ -66,13 +70,24 @@ const NotificationSections = () => {
};
return (
<div className="mt-4 px-4">
<div className="mt-4 px-4" data-testid="notification-tray-section">
{renderNotificationSection('today', today)}
{renderNotificationSection('earlier', earlier)}
{currentPage < numPages && (
<Button variant="primary" className="w-100 bg-primary-500" onClick={updatePagination}>
{intl.formatMessage(messages.loadMoreNotifications)}
</Button>
{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>
);

View File

@@ -17,7 +17,7 @@ const NotificationTabs = () => {
const { currentPage } = useSelector(selectPaginationData());
useEffect(() => {
dispatch(fetchNotificationList({ appName: selectedAppName, page: currentPage, pageSize: 10 }));
dispatch(fetchNotificationList({ appName: selectedAppName, page: currentPage }));
if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); }
}, [currentPage, selectedAppName]);
@@ -32,6 +32,7 @@ const NotificationTabs = () => {
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>

View File

@@ -0,0 +1 @@
import './notifications.factory';

View File

@@ -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() }));

View File

@@ -1,40 +1,39 @@
import { camelCaseObject } from '@edx/frontend-platform';
import notificationsList from './notifications.json';
import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export async function getNotifications(appName, page, pageSize) {
const { data } = notificationsList;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
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/`;
const notifications = data.slice(startIndex, endIndex);
return { notifications: camelCaseObject(notifications), numPages: 2, currentPage: page };
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 = {
count: 45,
count_by_app_name: {
reminders: 10,
discussions: 20,
grades: 10,
authoring: 5,
},
show_notification_tray: false,
};
return camelCaseObject(data);
const { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl());
return data;
}
export async function markNotificationSeen() {
const data = [];
return camelCaseObject(data);
export async function markNotificationSeen(appName) {
const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`);
return data;
}
export async function markAllNotificationRead() {
const { data } = camelCaseObject(notificationsList);
export async function markAllNotificationRead(appName) {
const params = snakeCaseObject({ appName });
const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params);
return data;
}
export async function markNotificationRead(notificationId) {
const { data } = camelCaseObject(notificationsList);
const params = snakeCaseObject({ notificationId });
const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params);
return { data, id: notificationId };
}

View File

@@ -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);
}
});
});

View File

@@ -0,0 +1,155 @@
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 { initializeStore } from '../../store';
import executeThunk from '../../test-utils';
import mockNotificationsResponse from '../test-utils';
import {
getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
} from './api';
import {
fetchAppsNotificationCount, fetchNotificationList, markNotificationsAsRead, markAllNotificationsAsRead,
resetNotificationState, markNotificationsAsSeen,
} from './thunks';
import './__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsListApiUrl = getNotificationsListApiUrl();
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion');
let axiosMock;
let store;
describe('Notification Redux', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
afterEach(() => {
axiosMock.reset();
});
it('Successfully loaded initial notification states in the redux.', async () => {
executeThunk(resetNotificationState(), store.dispatch, store.getState);
const { notifications } = store.getState();
expect(notifications.notificationStatus).toEqual('idle');
expect(notifications.appName).toEqual('discussion');
expect(notifications.appsId).toHaveLength(0);
expect(notifications.apps).toEqual({});
expect(notifications.notifications).toEqual({});
expect(notifications.tabsCount).toEqual({});
expect(notifications.showNotificationsTray).toEqual(false);
expect(notifications.pagination).toEqual({});
});
it('Successfully loaded notifications list in the redux.', async () => {
const { notifications: { notifications } } = store.getState();
expect(Object.keys(notifications)).toHaveLength(10);
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to load notifications list in the redux.', async ({ statusCode, status }) => {
axiosMock.onGet(notificationsListApiUrl).reply(statusCode);
await executeThunk(fetchNotificationList({ page: 1 }), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
it('Successfully loaded notification counts in the redux.', async () => {
const { notifications: { tabsCount } } = store.getState();
expect(tabsCount.count).toEqual(25);
expect(tabsCount.reminders).toEqual(10);
expect(tabsCount.discussion).toEqual(0);
expect(tabsCount.grades).toEqual(10);
expect(tabsCount.authoring).toEqual(5);
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to load notification counts in the redux.', async ({ statusCode, status }) => {
axiosMock.onGet(notificationCountsApiUrl).reply(statusCode);
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
it('Successfully marked all notifications as seen for selected app.', async () => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200);
await executeThunk(markNotificationsAsSeen('discussion'), store.dispatch, store.getState);
expect(store.getState().notifications.notificationStatus).toEqual('successful');
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to mark all notifications as seen for selected app.', async ({ statusCode, status }) => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(statusCode);
await executeThunk(markNotificationsAsSeen('discussion'), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
it('Successfully marked all notifications as read for selected app in the redux.', async () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200);
await executeThunk(markAllNotificationsAsRead('discussion'), store.dispatch, store.getState);
const { notifications: { notificationStatus, notifications } } = store.getState();
const firstNotification = Object.values(notifications)[0];
expect(notificationStatus).toEqual('successful');
expect(firstNotification.lastRead).not.toBeNull();
});
it('Successfully marked notification as read in the redux.', async () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200);
await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState);
const { notifications: { notificationStatus, notifications } } = store.getState();
const firstNotification = Object.values(notifications)[0];
expect(notificationStatus).toEqual('successful');
expect(firstNotification.lastRead).not.toBeNull();
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to marked notification as read in the redux.', async ({ statusCode, status }) => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode);
await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
});

View File

@@ -0,0 +1,115 @@
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 { initializeStore } from '../../store';
import mockNotificationsResponse from '../test-utils';
import {
selectNotifications,
selectNotificationsByIds,
selectNotificationStatus,
selectNotificationTabs,
selectNotificationTabsCount,
selectPaginationData,
selectSelectedAppName,
selectSelectedAppNotificationIds,
selectShowNotificationTray,
} from './selectors';
import './__factories__';
let axiosMock;
let store;
describe('Notification Selectors', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
afterEach(() => {
axiosMock.reset();
});
it('Should return notification status.', async () => {
const state = store.getState();
const status = selectNotificationStatus()(state);
expect(status).toEqual('successful');
});
it('Should return notification tabs count.', async () => {
const state = store.getState();
const tabsCount = selectNotificationTabsCount()(state);
expect(tabsCount.count).toEqual(25);
expect(tabsCount.reminders).toEqual(10);
expect(tabsCount.discussion).toEqual(0);
expect(tabsCount.grades).toEqual(10);
expect(tabsCount.authoring).toEqual(5);
});
it('Should return notification tabs.', async () => {
const state = store.getState();
const tabs = selectNotificationTabs()(state);
expect(tabs).toHaveLength(4);
});
it('Should return selected app notification ids.', async () => {
const state = store.getState();
const notificationIds = selectSelectedAppNotificationIds('discussion')(state);
expect(notificationIds).toHaveLength(10);
});
it('Should return show notification tray status.', async () => {
const state = store.getState();
const showNotificationTrayStatus = selectShowNotificationTray()(state);
expect(showNotificationTrayStatus).toEqual(true);
});
it('Should return notifications.', async () => {
const state = store.getState();
const notifications = selectNotifications()(state);
expect(Object.keys(notifications)).toHaveLength(10);
});
it('Should return notifications from Ids.', async () => {
const state = store.getState();
const notifications = selectNotificationsByIds('discussion')(state);
expect(notifications).toHaveLength(10);
});
it('Should return selected app name.', async () => {
const state = store.getState();
const appName = selectSelectedAppName()(state);
expect(appName).toEqual('discussion');
});
it('Should return pagination data.', async () => {
const state = store.getState();
const paginationData = selectPaginationData()(state);
expect(paginationData.currentPage).toEqual(1);
expect(paginationData.numPages).toEqual(2);
expect(paginationData.hasMorePages).toEqual(true);
});
});

View File

@@ -8,7 +8,7 @@ export const selectNotificationTabs = () => state => state.notifications.appsId;
export const selectSelectedAppNotificationIds = (appName) => state => state.notifications.apps[appName] ?? [];
export const selectShowNotificationTray = () => state => state.notifications.showNotificationTray;
export const selectShowNotificationTray = () => state => state.notifications.showNotificationsTray;
export const selectNotifications = () => state => state.notifications.notifications;

View File

@@ -3,26 +3,21 @@ import { createSlice } from '@reduxjs/toolkit';
export const RequestStatus = {
IDLE: 'idle',
LOADING: 'in-progress',
LOADED: 'successful',
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',
FAILED: 'failed',
DENIED: 'denied',
};
const initialState = {
notificationStatus: 'idle',
appName: 'discussions',
notificationStatus: RequestStatus.IDLE,
appName: 'discussion',
appsId: [],
apps: {},
notifications: {},
tabsCount: {},
showNotificationTray: false,
pagination: {
count: 10,
numPages: 1,
currentPage: 1,
nextPage: null,
},
showNotificationsTray: false,
pagination: {},
};
const slice = createSlice({
name: 'notifications',
@@ -35,21 +30,19 @@ const slice = createSlice({
state.notificationStatus = RequestStatus.FAILED;
},
fetchNotificationRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
fetchNotificationSuccess: (state, { payload }) => {
const {
newNotificationIds, notificationsKeyValuePair, numPages, currentPage,
newNotificationIds, notificationsKeyValuePair, pagination,
} = payload;
const existingNotificationIds = state.apps[state.appName];
state.apps[state.appName] = Array.from(new Set([...existingNotificationIds, ...newNotificationIds]));
state.notifications = { ...state.notifications, ...notificationsKeyValuePair };
state.tabsCount.count -= state.tabsCount[state.appName];
state.tabsCount[state.appName] = 0;
state.notificationStatus = RequestStatus.LOADED;
state.pagination.numPages = numPages;
state.pagination.currentPage = currentPage;
state.notificationStatus = RequestStatus.SUCCESSFUL;
state.pagination = pagination;
},
fetchNotificationsCountDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
@@ -58,23 +51,23 @@ const slice = createSlice({
state.notificationStatus = RequestStatus.FAILED;
},
fetchNotificationsCountRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
fetchNotificationsCountSuccess: (state, { payload }) => {
const {
countByAppName, appIds, apps, count, showNotificationTray,
countByAppName, appIds, apps, count, showNotificationsTray,
} = payload;
state.tabsCount = { count, ...countByAppName };
state.appsId = appIds;
state.apps = apps;
state.showNotificationTray = showNotificationTray;
state.notificationStatus = RequestStatus.LOADED;
state.showNotificationsTray = showNotificationsTray;
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markNotificationsAsSeenRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
markNotificationsAsSeenSuccess: (state) => {
state.notificationStatus = RequestStatus.LOADED;
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markNotificationsAsSeenDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
@@ -83,7 +76,7 @@ const slice = createSlice({
state.notificationStatus = RequestStatus.FAILED;
},
markAllNotificationsAsReadRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
markAllNotificationsAsReadSuccess: (state) => {
const updatedNotifications = Object.fromEntries(
@@ -92,7 +85,7 @@ const slice = createSlice({
]),
);
state.notifications = updatedNotifications;
state.notificationStatus = RequestStatus.LOADED;
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markAllNotificationsAsReadDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
@@ -101,12 +94,12 @@ const slice = createSlice({
state.notificationStatus = RequestStatus.FAILED;
},
markNotificationsAsReadRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
markNotificationsAsReadSuccess: (state, { payload }) => {
const date = new Date().toISOString();
state.notifications[payload.id] = { ...state.notifications[payload.id], lastRead: date };
state.notificationStatus = RequestStatus.LOADED;
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markNotificationsAsReadDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;

View File

@@ -1,3 +1,4 @@
import { camelCaseObject } from '@edx/frontend-platform';
import {
fetchNotificationSuccess,
fetchNotificationRequest,
@@ -22,33 +23,38 @@ import {
markNotificationsAsReadFailure,
} from './slice';
import {
getNotifications, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead,
getNotificationsList, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead,
} from './api';
import { getHttpErrorStatus } from '../utils';
const normalizeNotificationCounts = ({ countByAppName, count, showNotificationTray }) => {
const normalizeNotificationCounts = ({ countByAppName, count, showNotificationsTray }) => {
const appIds = Object.keys(countByAppName);
const apps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {});
return {
countByAppName, appIds, apps, count, showNotificationTray,
countByAppName, appIds, apps, count, showNotificationsTray,
};
};
const normalizeNotifications = ({ notifications }) => {
const newNotificationIds = notifications.map(notification => notification.id.toString());
const notificationsKeyValuePair = notifications.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {});
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,
newNotificationIds, notificationsKeyValuePair, pagination,
};
};
export const fetchNotificationList = ({ appName, page, pageSize }) => (
export const fetchNotificationList = ({ appName, page }) => (
async (dispatch) => {
try {
dispatch(fetchNotificationRequest({ appName }));
const data = await getNotifications(appName, page, pageSize);
const normalisedData = normalizeNotifications((data));
dispatch(fetchNotificationSuccess({ ...normalisedData, numPages: data.numPages, currentPage: data.currentPage }));
const data = await getNotificationsList(appName, page);
const normalisedData = normalizeNotifications((camelCaseObject(data)));
dispatch(fetchNotificationSuccess({ ...normalisedData }));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchNotificationDenied(appName));
@@ -64,13 +70,8 @@ export const fetchAppsNotificationCount = () => (
try {
dispatch(fetchNotificationsCountRequest());
const data = await getNotificationCounts();
const normalisedData = normalizeNotificationCounts((data));
dispatch(fetchNotificationsCountSuccess({
...normalisedData,
countByAppName: data.countByAppName,
count: data.count,
showNotificationTray: data.showNotificationTray,
}));
const normalisedData = normalizeNotificationCounts((camelCaseObject(data)));
dispatch(fetchNotificationsCountSuccess({ ...normalisedData }));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchNotificationsCountDenied());
@@ -86,7 +87,7 @@ export const markAllNotificationsAsRead = (appName) => (
try {
dispatch(markAllNotificationsAsReadRequest({ appName }));
const data = await markAllNotificationRead(appName);
dispatch(markAllNotificationsAsReadSuccess(data));
dispatch(markAllNotificationsAsReadSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markAllNotificationsAsReadDenied());
@@ -102,7 +103,7 @@ export const markNotificationsAsRead = (notificationId) => (
try {
dispatch(markNotificationsAsReadRequest({ notificationId }));
const data = await markNotificationRead(notificationId);
dispatch(markNotificationsAsReadSuccess(data));
dispatch(markNotificationsAsReadSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markNotificationsAsReadDenied());
@@ -118,7 +119,7 @@ export const markNotificationsAsSeen = (appName) => (
try {
dispatch(markNotificationsAsSeenRequest({ appName }));
const data = await markNotificationSeen(appName);
dispatch(markNotificationsAsSeenSuccess(data));
dispatch(markNotificationsAsSeenSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markNotificationsAsSeenDenied());

View File

@@ -54,7 +54,7 @@ const Notifications = () => {
overlay={(
<Popover
id="notificationTray"
data-testid="notificationTray"
data-testid="notification-tray"
className={classNames('overflow-auto rounded-0 border-0', {
'w-100': !isOnMediumScreen && !isOnLargeScreen,
'medium-screen': isOnMediumScreen,
@@ -64,7 +64,7 @@ const Notifications = () => {
<div ref={popoverRef}>
<Popover.Title as="h2" className="d-flex justify-content-between p-0 m-4 border-0 text-primary-500 font-size-18 line-height-24">
{intl.formatMessage(messages.notificationTitle)}
<Icon src={Settings} className="icon-size-20" />
<Icon src={Settings} className="icon-size-20" data-testid="setting-icon" />
</Popover.Title>
<Popover.Content className="notification-content p-0">
<NotificationTabs />
@@ -83,12 +83,14 @@ const Notifications = () => {
variant="light"
iconClassNames="text-primary-500"
className="ml-4 mr-1 my-3 notification-button"
data-testid="notification-bell-icon"
/>
{notificationCounts?.count > 0 && (
<Badge
pill
variant="danger"
className="font-weight-normal px-1 notification-badge"
data-testid="notification-count"
>
{notificationCounts.count}
</Badge>

View File

@@ -0,0 +1,109 @@
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 { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import executeThunk from '../test-utils';
import { getNotificationsCountApiUrl } from './data/api';
import { fetchAppsNotificationCount } from './data/thunks';
import './data/__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
let axiosMock;
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
});
async function setupMockNotificationCountResponse(count = 45, showNotificationsTray = true) {
axiosMock.onGet(notificationCountsApiUrl)
.reply(200, (Factory.build('notificationsCount', { count, showNotificationsTray })));
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
}
it('Successfully showed bell icon and unseen count on it if unseen count is greater then 0.', async () => {
await setupMockNotificationCountResponse();
renderComponent();
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);
renderComponent();
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);
renderComponent();
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();
renderComponent();
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());
});
});

View File

@@ -0,0 +1,87 @@
import React from 'react';
import {
act, fireEvent, render, screen,
} 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 { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import { markNotificationAsReadApiUrl } from './data/api';
import mockNotificationsResponse from './test-utils';
import './data/__factories__';
const markedNotificationAsReadApiUrl = markNotificationAsReadApiUrl();
let axiosMock;
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification row item test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
it(
'Successfully viewed notification icon, notification context, unread , course name and notification time.',
async () => {
renderComponent();
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 () => {
axiosMock.onPatch(markedNotificationAsReadApiUrl).reply(200, { message: 'Notification marked read.' });
renderComponent();
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();
});
});

View File

@@ -0,0 +1,106 @@
import React from 'react';
import {
act, fireEvent, render, screen, 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 { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import { markNotificationAsReadApiUrl, markNotificationsSeenApiUrl, getNotificationsListApiUrl } from './data/api';
import mockNotificationsResponse from './test-utils';
import { markNotificationsAsSeen, fetchNotificationList } from './data/thunks';
import executeThunk from '../test-utils';
import './data/__factories__';
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();
let axiosMock;
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification sections test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
it('Successfully viewed last 24 hours and earlier section along with mark all as read label.', async () => {
renderComponent();
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 () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' });
renderComponent();
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); });
expect(screen.queryByTestId('unread-notification-1')).not.toBeInTheDocument();
});
it('Successfully load more notifications by clicking on load more notification button.', async () => {
axiosMock.onPut(markNotificationsSeenApiUrl('discussion')).reply(200);
await executeThunk(markNotificationsAsSeen('discussions'), store.dispatch, store.getState);
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
expect(screen.queryAllByTestId('notification-contents')).toHaveLength(10);
const loadMoreButton = screen.queryByTestId('load-more-notifications');
axiosMock.onGet(getNotificationsListApiUrl()).reply(
200,
(Factory.build('notificationsList', { num_pages: 2, current_page: 2 })),
);
await executeThunk(fetchNotificationList({ appName: 'discussion', page: 2 }), store.dispatch, store.getState);
await act(async () => { fireEvent.click(loadMoreButton); });
expect(screen.queryAllByTestId('notification-contents')).toHaveLength(12);
});
});

View File

@@ -0,0 +1,91 @@
import React from 'react';
import {
act, fireEvent, render, screen, 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 { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import mockNotificationsResponse from './test-utils';
import './data/__factories__';
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification Tabs test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
Factory.resetAll();
store = initializeStore();
({ store } = await mockNotificationsResponse());
});
it('Notification tabs displayed with default discussion tab selected and no unseen counts.', async () => {
renderComponent();
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();
expect(within(selectedTab).queryByRole('status')).not.toBeInTheDocument();
});
it('Successfully showed unseen counts for unselected tabs.', async () => {
renderComponent();
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 () => {
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
const notificationTab = screen.getAllByRole('tab');
await act(async () => { fireEvent.click(notificationTab[0], { dataset: { rbEventKey: 'reminders' } }); });
const tabs = screen.queryAllByRole('tab');
const selectedTab = tabs.find(tab => tab.getAttribute('aria-selected') === 'true');
expect(within(selectedTab).queryByText('reminders')).toBeInTheDocument();
expect(within(selectedTab).queryByRole('status')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,32 @@
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeStore } from '../store';
import executeThunk from '../test-utils';
import { getNotificationsListApiUrl, getNotificationsCountApiUrl } from './data/api';
import { fetchAppsNotificationCount, fetchNotificationList } from './data/thunks';
import './data/__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsListApiUrl();
export default async function mockNotificationsResponse() {
const store = initializeStore();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const notifications = (Factory.buildList('notification', 8, null, { createdDate: new Date().toISOString() }).concat(
Factory.buildList('notification', 2, null, { createdDate: '2023-06-01T00:46:11.979531Z' }),
));
axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount')));
axiosMock.onGet(notificationsApiUrl).reply(200, (Factory.build('notificationsList', {
results: notifications,
num_pages: 2,
next: `${notificationsApiUrl}?app_name=discussion&page=2`,
})));
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
await executeThunk(fetchNotificationList({ appName: 'discussion', page: 1 }), store.dispatch, store.getState);
return { store, axiosMock };
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "الحساب",
"header.menu.orderHistory.label": "سجل الطلبيات",
"header.navigation.skipNavLink": "التخطي إلى المحتوى الرئيسي",
"header.menu.signOut.label": "تسجيل الخروج"
"header.menu.signOut.label": "تسجيل الخروج",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"header.menu.signOut.label": "Sign Out",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Cuenta",
"header.menu.orderHistory.label": "Historial de órdenes",
"header.navigation.skipNavLink": "Dirígete al contenido principal.",
"header.menu.signOut.label": "Cerrar sesión"
"header.menu.signOut.label": "Cerrar sesión",
"notification.title": "Notificaciones",
"notification.today.heading": "Últimas 24 horas",
"notification.earlier.heading": "Más temprano",
"notification.mark.as.read": "Marcar todo como leído",
"notification.fullStop": "•",
"notification.load.more.notifications": "Cargar más notificaciones"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Compte",
"header.menu.orderHistory.label": "Historique des commandes",
"header.navigation.skipNavLink": "Passer au contenu principal",
"header.menu.signOut.label": "Se déconnecter"
"header.menu.signOut.label": "Se déconnecter",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Compte",
"header.menu.orderHistory.label": "Historique des commandes",
"header.navigation.skipNavLink": "Passer au contenu principal.",
"header.menu.signOut.label": "Se déconnecter"
"header.menu.signOut.label": "Se déconnecter",
"notification.title": "Notifications",
"notification.today.heading": "Dernières 24 heures",
"notification.earlier.heading": "Plus tôt",
"notification.mark.as.read": "tout marquer comme lu",
"notification.fullStop": "•",
"notification.load.more.notifications": "Charger plus de notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"header.menu.signOut.label": "Sign Out",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"header.menu.signOut.label": "Sign Out",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"header.menu.signOut.label": "Sign Out",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"header.menu.signOut.label": "Sign Out",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Перейти до головного змісту.",
"header.menu.signOut.label": "Sign Out"
"header.menu.signOut.label": "Sign Out",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"header.menu.signOut.label": "Sign Out",
"notification.title": "Notifications",
"notification.today.heading": "Last 24 hours",
"notification.earlier.heading": "Earlier",
"notification.mark.as.read": "Mark all as read",
"notification.fullStop": "•",
"notification.load.more.notifications": "Load more notifications"
}

View File

@@ -123,7 +123,7 @@ $white: #fff;
}
.content {
b {
strong {
color: #00262B !important;
font-weight: 500 !important;
}

View File

@@ -15,7 +15,7 @@ import { RequestStatus } from '../Notifications/data/slice';
import messages from './messages';
const AuthenticatedUserDropdown = ({ intl, username }) => {
const showNotificationTray = useSelector(selectShowNotificationTray());
const showNotificationsTray = useSelector(selectShowNotificationTray());
const notificationStatus = useSelector(selectNotificationStatus());
const dispatch = useDispatch();
@@ -24,7 +24,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
dispatch(fetchAppsNotificationCount());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notificationStatus]);
}, []);
const dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
@@ -35,7 +35,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
return (
<>
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
{showNotificationTray && <Notifications />}
{showNotificationsTray && <Notifications />}
<Dropdown className="user-dropdown ml-3">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />

View File

@@ -3,7 +3,7 @@
import Enzyme from 'enzyme';
import React from 'react';
import PropTypes from 'prop-types';
import Adapter from 'enzyme-adapter-react-16';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import 'babel-polyfill';

6
src/test-utils.js Normal file
View File

@@ -0,0 +1,6 @@
const executeThunk = async (thunk, dispatch, getState) => {
await thunk(dispatch, getState);
await new Promise(setImmediate);
};
export default executeThunk;