Skip to content

Commit

Permalink
feat: integrated notifications tray with backend apis (#363)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ayesha-waris authored Jul 10, 2023
1 parent 42e831a commit 0ce451c
Show file tree
Hide file tree
Showing 15 changed files with 146 additions and 149 deletions.
35 changes: 22 additions & 13 deletions src/Notifications/NotificationSections.jsx
Original file line number Diff line number Diff line change
@@ -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],
Expand Down Expand Up @@ -70,15 +73,21 @@ const NotificationSections = () => {
<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}
data-testid="load-more-notifications"
>
{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>
);
Expand Down
2 changes: 1 addition & 1 deletion src/Notifications/NotificationTabs.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
15 changes: 12 additions & 3 deletions src/Notifications/data/__factories__/notifications.factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Factory.define('notificationsCount')
.attr('count', 45)
.attr('countByAppName', {
reminders: 10,
discussions: 20,
discussion: 20,
grades: 10,
authoring: 5,
})
Expand All @@ -13,10 +13,19 @@ Factory.define('notificationsCount')
Factory.define('notification')
.sequence('id')
.attr('type', 'post')
.sequence('content', ['id'], (idx, notificationId) => `<p><b>User ${idx}</b> posts <b>Hello and welcome to SC0x
${notificationId}!</b></p>`)
.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() }));
21 changes: 8 additions & 13 deletions src/Notifications/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,14 @@ import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

export const getNotificationsCountApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/count/`;
export const getNotificationsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`;
export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-notifications-unseen/${appName}/`;
export const getNotificationsListApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`;
export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-seen/${appName}/`;
export const markNotificationAsReadApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/read/`;

export async function getNotifications(appName, page, pageSize) {
const params = snakeCaseObject({ page, pageSize });
const { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params });

const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;

const notifications = data.slice(startIndex, endIndex);
return { 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() {
Expand All @@ -31,14 +26,14 @@ export async function markNotificationSeen(appName) {

export async function markAllNotificationRead(appName) {
const params = snakeCaseObject({ appName });
const { data } = await getAuthenticatedHttpClient().put(markNotificationAsReadApiUrl(), { params });
const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params);

return data;
}

export async function markNotificationRead(notificationId) {
const params = snakeCaseObject({ notificationId });
const { data } = await getAuthenticatedHttpClient().put(markNotificationAsReadApiUrl(), { params });
const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params);

return { data, id: notificationId };
}
37 changes: 17 additions & 20 deletions src/Notifications/data/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';

import {
getNotificationsApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
getNotificationCounts, getNotifications, markNotificationSeen, markAllNotificationRead, markNotificationRead,
getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
getNotificationCounts, getNotificationsList, markNotificationSeen, markAllNotificationRead, markNotificationRead,
} from './api';

import './__factories__';

const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussions');
const notificationsApiUrl = getNotificationsListApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion');
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();

let axiosMock = null;
Expand Down Expand Up @@ -43,7 +43,7 @@ describe('Notifications API', () => {

expect(count).toEqual(45);
expect(countByAppName.reminders).toEqual(10);
expect(countByAppName.discussions).toEqual(20);
expect(countByAppName.discussion).toEqual(20);
expect(countByAppName.grades).toEqual(10);
expect(countByAppName.authoring).toEqual(5);
});
Expand All @@ -62,14 +62,11 @@ describe('Notifications API', () => {
});

it('Successfully get notifications.', async () => {
axiosMock.onGet(notificationsApiUrl).reply(
200,
(Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })),
);
axiosMock.onGet(notificationsApiUrl).reply(200, (Factory.build('notificationsList')));

const { notifications } = await getNotifications('discussions', 1, 10);
const notifications = await getNotificationsList('discussion', 1);

expect(notifications).toHaveLength(2);
expect(notifications.results).toHaveLength(2);
});

it.each([
Expand All @@ -78,7 +75,7 @@ describe('Notifications API', () => {
])('%s for notification API.', async ({ statusCode, message }) => {
axiosMock.onGet(notificationsApiUrl).reply(statusCode, { message });
try {
await getNotifications({ page: 1, pageSize: 10 });
await getNotificationsList('discussion', 1);
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
Expand All @@ -88,7 +85,7 @@ describe('Notifications API', () => {
it('Successfully marked all notifications as seen for selected app.', async () => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200, { message: 'Notifications marked seen.' });

const { message } = await markNotificationSeen('discussions');
const { message } = await markNotificationSeen('discussion');

expect(message).toEqual('Notifications marked seen.');
});
Expand All @@ -99,17 +96,17 @@ describe('Notifications API', () => {
])('%s for notification mark as seen API.', async ({ statusCode, message }) => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(statusCode, { message });
try {
await markNotificationSeen('discussions');
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.onPut(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' });
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' });

const { message } = await markAllNotificationRead('discussions');
const { message } = await markAllNotificationRead('discussion');

expect(message).toEqual('Notifications marked read.');
});
Expand All @@ -118,17 +115,17 @@ describe('Notifications API', () => {
{ 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.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
try {
await markAllNotificationRead('discussions');
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.onPut(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notification marked read.' });
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notification marked read.' });

const { data } = await markNotificationRead(1);

Expand All @@ -139,7 +136,7 @@ describe('Notifications API', () => {
{ 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.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
try {
await markAllNotificationRead(1);
} catch (error) {
Expand Down
43 changes: 17 additions & 26 deletions src/Notifications/data/redux.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';

import { initializeStore } from '../../store';
import executeThunk from '../../test-utils';
import mockNotificationsResponse from '../test-utils';
import {
getNotificationsApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
} from './api';
import {
fetchAppsNotificationCount, fetchNotificationList, markNotificationsAsRead, markAllNotificationsAsRead,
Expand All @@ -17,9 +18,9 @@ import {
import './__factories__';

const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsApiUrl();
const notificationsListApiUrl = getNotificationsListApiUrl();
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussions');
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion');

let axiosMock;
let store;
Expand All @@ -38,13 +39,7 @@ describe('Notification Redux', () => {
Factory.resetAll();
store = initializeStore();

axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount')));
axiosMock.onGet(notificationsApiUrl).reply(
200,
(Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })),
);
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), store.dispatch, store.getState);
({ store, axiosMock } = await mockNotificationsResponse());
});

afterEach(() => {
Expand All @@ -57,30 +52,26 @@ describe('Notification Redux', () => {
const { notifications } = store.getState();

expect(notifications.notificationStatus).toEqual('idle');
expect(notifications.appName).toEqual('discussions');
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.count).toEqual(10);
expect(notifications.pagination.numPages).toEqual(1);
expect(notifications.pagination.currentPage).toEqual(1);
expect(notifications.pagination.nextPage).toBeNull();
expect(notifications.pagination).toEqual({});
});

it('Successfully loaded notifications list in the redux.', async () => {
const { notifications: { notifications } } = store.getState();

expect(Object.keys(notifications)).toHaveLength(2);
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(notificationsApiUrl).reply(statusCode);
await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), store.dispatch, store.getState);
axiosMock.onGet(notificationsListApiUrl).reply(statusCode);
await executeThunk(fetchNotificationList({ page: 1 }), store.dispatch, store.getState);

const { notifications: { notificationStatus } } = store.getState();

Expand All @@ -92,7 +83,7 @@ describe('Notification Redux', () => {

expect(tabsCount.count).toEqual(25);
expect(tabsCount.reminders).toEqual(10);
expect(tabsCount.discussions).toEqual(0);
expect(tabsCount.discussion).toEqual(0);
expect(tabsCount.grades).toEqual(10);
expect(tabsCount.authoring).toEqual(5);
});
Expand All @@ -111,7 +102,7 @@ describe('Notification Redux', () => {

it('Successfully marked all notifications as seen for selected app.', async () => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200);
await executeThunk(markNotificationsAsSeen('discussions'), store.dispatch, store.getState);
await executeThunk(markNotificationsAsSeen('discussion'), store.dispatch, store.getState);

expect(store.getState().notifications.notificationStatus).toEqual('successful');
});
Expand All @@ -121,16 +112,16 @@ describe('Notification Redux', () => {
{ 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('discussions'), store.dispatch, store.getState);
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.onPut(markedAllNotificationsAsReadApiUrl).reply(200);
await executeThunk(markAllNotificationsAsRead('discussions'), store.dispatch, store.getState);
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];
Expand All @@ -140,7 +131,7 @@ describe('Notification Redux', () => {
});

it('Successfully marked notification as read in the redux.', async () => {
axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(200);
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200);
await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState);

const { notifications: { notificationStatus, notifications } } = store.getState();
Expand All @@ -154,7 +145,7 @@ describe('Notification Redux', () => {
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to marked notification as read in the redux.', async ({ statusCode, status }) => {
axiosMock.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode);
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode);
await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState);

const { notifications: { notificationStatus } } = store.getState();
Expand Down
Loading

0 comments on commit 0ce451c

Please sign in to comment.