Converts NotificationList to functional component and now uses useRequest

This commit is contained in:
mabashian 2020-07-31 10:48:41 -04:00
parent d36999acc7
commit d8af8baae3
2 changed files with 266 additions and 381 deletions

View File

@ -1,15 +1,14 @@
import React, { Component, Fragment } from 'react';
import { number, shape, string, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import React, { useEffect, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { number, shape, bool } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import NotificationListItem from './NotificationListItem';
import PaginatedDataList from '../PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../util/qs';
import useRequest from '../../util/useRequest';
import { NotificationTemplatesAPI } from '../../api';
const QS_CONFIG = getQSConfig('notification', {
@ -18,64 +17,49 @@ const QS_CONFIG = getQSConfig('notification', {
order_by: 'name',
});
class NotificationList extends Component {
constructor(props) {
super(props);
this.state = {
contentError: null,
hasContentLoading: true,
toggleError: false,
loadingToggleIds: [],
itemCount: 0,
notifications: [],
startedTemplateIds: [],
successTemplateIds: [],
errorTemplateIds: [],
typeLabels: null,
};
this.handleNotificationToggle = this.handleNotificationToggle.bind(this);
this.handleNotificationErrorClose = this.handleNotificationErrorClose.bind(
this
);
this.loadNotifications = this.loadNotifications.bind(this);
}
function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
const location = useLocation();
const [isToggleLoading, setIsToggleLoading] = useState(false);
const [toggleError, setToggleError] = useState(null);
componentDidMount() {
this.loadNotifications();
}
const {
result: fetchNotificationsResult,
result: {
notifications,
itemCount,
startedTemplateIds,
successTemplateIds,
errorTemplateIds,
typeLabels,
},
error: contentError,
isLoading,
request: fetchNotifications,
setValue,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [
{
data: { results: notificationsResults, count: notificationsCount },
},
{
data: { actions },
},
] = await Promise.all([
NotificationTemplatesAPI.read(params),
NotificationTemplatesAPI.readOptions(),
]);
componentDidUpdate(prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.loadNotifications();
}
}
const labels = actions.GET.notification_type.choices.reduce(
(map, notifType) => ({ ...map, [notifType[0]]: notifType[1] }),
{}
);
async loadNotifications() {
const { id, location, apiModel } = this.props;
const { typeLabels } = this.state;
const params = parseQueryString(QS_CONFIG, location.search);
const promises = [NotificationTemplatesAPI.read(params)];
if (!typeLabels) {
promises.push(NotificationTemplatesAPI.readOptions());
}
this.setState({ contentError: null, hasContentLoading: true });
try {
const {
data: { count: itemCount = 0, results: notifications = [] },
} = await NotificationTemplatesAPI.read(params);
const optionsResponse = await NotificationTemplatesAPI.readOptions();
let idMatchParams;
if (notifications.length > 0) {
idMatchParams = { id__in: notifications.map(n => n.id).join(',') };
} else {
idMatchParams = {};
}
const idMatchParams =
notificationsResults.length > 0
? { id__in: notificationsResults.map(n => n.id).join(',') }
: {};
const [
{ data: startedTemplates },
@ -87,69 +71,35 @@ class NotificationList extends Component {
apiModel.readNotificationTemplatesError(id, idMatchParams),
]);
const stateToUpdate = {
itemCount,
notifications,
return {
notifications: notificationsResults,
itemCount: notificationsCount,
startedTemplateIds: startedTemplates.results.map(st => st.id),
successTemplateIds: successTemplates.results.map(su => su.id),
errorTemplateIds: errorTemplates.results.map(e => e.id),
typeLabels: labels,
};
if (!typeLabels) {
const {
data: {
actions: {
GET: {
notification_type: { choices },
},
},
},
} = optionsResponse;
// The structure of choices looks like [['slack', 'Slack'], ['email', 'Email'], ...]
stateToUpdate.typeLabels = choices.reduce(
(map, notifType) => ({ ...map, [notifType[0]]: notifType[1] }),
{}
);
}
this.setState(stateToUpdate);
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}, [apiModel, id, location]),
{
notifications: [],
itemCount: 0,
startedTemplateIds: [],
successTemplateIds: [],
errorTemplateIds: [],
typeLabels: {},
}
}
);
async handleNotificationToggle(notificationId, isCurrentlyOn, status) {
const { id, apiModel } = this.props;
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
let stateArrayName;
if (status === 'success') {
stateArrayName = 'successTemplateIds';
} else if (status === 'error') {
stateArrayName = 'errorTemplateIds';
} else if (status === 'started') {
stateArrayName = 'startedTemplateIds';
}
let stateUpdateFunction;
if (isCurrentlyOn) {
// when switching off, remove the toggled notification id from the array
stateUpdateFunction = prevState => ({
[stateArrayName]: prevState[stateArrayName].filter(
i => i !== notificationId
),
});
} else {
// when switching on, add the toggled notification id to the array
stateUpdateFunction = prevState => ({
[stateArrayName]: prevState[stateArrayName].concat(notificationId),
});
}
this.setState(({ loadingToggleIds }) => ({
loadingToggleIds: loadingToggleIds.concat([notificationId]),
}));
const handleNotificationToggle = async (
notificationId,
isCurrentlyOn,
status
) => {
setIsToggleLoading(true);
try {
if (isCurrentlyOn) {
await apiModel.disassociateNotificationTemplate(
@ -157,128 +107,111 @@ class NotificationList extends Component {
notificationId,
status
);
setValue({
...fetchNotificationsResult,
[`${status}TemplateIds`]: fetchNotificationsResult[
`${status}TemplateIds`
].filter(i => i !== notificationId),
});
} else {
await apiModel.associateNotificationTemplate(
id,
notificationId,
status
);
setValue({
...fetchNotificationsResult,
[`${status}TemplateIds`]: fetchNotificationsResult[
`${status}TemplateIds`
].concat(notificationId),
});
}
this.setState(stateUpdateFunction);
} catch (err) {
this.setState({ toggleError: err });
setToggleError(err);
} finally {
this.setState(({ loadingToggleIds }) => ({
loadingToggleIds: loadingToggleIds.filter(
item => item !== notificationId
),
}));
setIsToggleLoading(false);
}
}
};
handleNotificationErrorClose() {
this.setState({ toggleError: false });
}
render() {
const { canToggleNotifications, i18n } = this.props;
const {
contentError,
hasContentLoading,
toggleError,
loadingToggleIds,
itemCount,
notifications,
startedTemplateIds,
successTemplateIds,
errorTemplateIds,
typeLabels,
} = this.state;
return (
<Fragment>
<PaginatedDataList
contentError={contentError}
hasContentLoading={hasContentLoading}
items={notifications}
itemCount={itemCount}
pluralizedItemName={i18n._(t`Notifications`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Type`),
key: 'type',
options: [
['email', i18n._(t`Email`)],
['grafana', i18n._(t`Grafana`)],
['hipchat', i18n._(t`Hipchat`)],
['irc', i18n._(t`IRC`)],
['mattermost', i18n._(t`Mattermost`)],
['pagerduty', i18n._(t`Pagerduty`)],
['rocketchat', i18n._(t`Rocket.Chat`)],
['slack', i18n._(t`Slack`)],
['twilio', i18n._(t`Twilio`)],
['webhook', i18n._(t`Webhook`)],
],
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderItem={notification => (
<NotificationListItem
key={notification.id}
notification={notification}
detailUrl={`/notifications/${notification.id}`}
canToggleNotifications={
canToggleNotifications &&
!loadingToggleIds.includes(notification.id)
}
toggleNotification={this.handleNotificationToggle}
errorTurnedOn={errorTemplateIds.includes(notification.id)}
startedTurnedOn={startedTemplateIds.includes(notification.id)}
successTurnedOn={successTemplateIds.includes(notification.id)}
typeLabels={typeLabels}
/>
)}
/>
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={notifications}
itemCount={itemCount}
pluralizedItemName={i18n._(t`Notifications`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Type`),
key: 'type',
options: [
['email', i18n._(t`Email`)],
['grafana', i18n._(t`Grafana`)],
['hipchat', i18n._(t`Hipchat`)],
['irc', i18n._(t`IRC`)],
['mattermost', i18n._(t`Mattermost`)],
['pagerduty', i18n._(t`Pagerduty`)],
['rocketchat', i18n._(t`Rocket.Chat`)],
['slack', i18n._(t`Slack`)],
['twilio', i18n._(t`Twilio`)],
['webhook', i18n._(t`Webhook`)],
],
},
{
name: i18n._(t`Created by (username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified by (username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderItem={notification => (
<NotificationListItem
key={notification.id}
notification={notification}
detailUrl={`/notifications/${notification.id}`}
canToggleNotifications={canToggleNotifications && !isToggleLoading}
toggleNotification={handleNotificationToggle}
errorTurnedOn={errorTemplateIds.includes(notification.id)}
startedTurnedOn={startedTemplateIds.includes(notification.id)}
successTurnedOn={successTemplateIds.includes(notification.id)}
typeLabels={typeLabels}
/>
)}
/>
{toggleError && (
<AlertModal
variant="error"
title={i18n._(t`Error!`)}
isOpen={toggleError && loadingToggleIds.length === 0}
onClose={this.handleNotificationErrorClose}
isOpen={!isToggleLoading}
onClose={() => setToggleError(null)}
>
{i18n._(t`Failed to toggle notification.`)}
<ErrorDetail error={toggleError} />
</AlertModal>
</Fragment>
);
}
)}
</>
);
}
NotificationList.propTypes = {
apiModel: shape({}).isRequired,
id: number.isRequired,
canToggleNotifications: bool.isRequired,
location: shape({
search: string.isRequired,
}).isRequired,
};
export { NotificationList as _NotificationList };
export default withI18n()(withRouter(NotificationList));
export default withI18n()(NotificationList);

View File

@ -1,15 +1,13 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
import { NotificationTemplatesAPI } from '../../api';
import NotificationList from './NotificationList';
jest.mock('../../api');
describe('<NotificationList />', () => {
let wrapper;
const data = {
count: 2,
results: [
@ -58,220 +56,185 @@ describe('<NotificationList />', () => {
},
});
beforeEach(() => {
NotificationTemplatesAPI.read.mockReturnValue({ data });
MockModelAPI.readNotificationTemplatesSuccess.mockReturnValue({
data: { results: [{ id: 1 }] },
});
MockModelAPI.readNotificationTemplatesError.mockReturnValue({
data: { results: [{ id: 2 }] },
});
MockModelAPI.readNotificationTemplatesStarted.mockReturnValue({
data: { results: [{ id: 3 }] },
NotificationTemplatesAPI.read.mockReturnValue({ data });
MockModelAPI.readNotificationTemplatesSuccess.mockReturnValue({
data: { results: [{ id: 1 }] },
});
MockModelAPI.readNotificationTemplatesError.mockReturnValue({
data: { results: [{ id: 2 }] },
});
MockModelAPI.readNotificationTemplatesStarted.mockReturnValue({
data: { results: [{ id: 3 }] },
});
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<NotificationList
id={1}
canToggleNotifications
apiModel={MockModelAPI}
/>
);
});
wrapper.update();
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders succesfully', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
wrapper.update();
const dataList = wrapper.find('PaginatedDataList');
expect(dataList).toHaveLength(1);
expect(dataList.prop('items')).toEqual(data.results);
test('initially renders succesfully', () => {
expect(wrapper.find('PaginatedDataList')).toHaveLength(1);
});
test('should render list fetched of items', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
wrapper.update();
test('should render list fetched of items', () => {
expect(NotificationTemplatesAPI.read).toHaveBeenCalled();
expect(wrapper.find('NotificationList').state('notifications')).toEqual(
data.results
);
const items = wrapper.find('NotificationListItem');
expect(items).toHaveLength(3);
expect(items.at(0).prop('successTurnedOn')).toEqual(true);
expect(items.at(0).prop('errorTurnedOn')).toEqual(false);
expect(items.at(0).prop('startedTurnedOn')).toEqual(false);
expect(items.at(1).prop('successTurnedOn')).toEqual(false);
expect(items.at(1).prop('errorTurnedOn')).toEqual(true);
expect(items.at(1).prop('startedTurnedOn')).toEqual(false);
expect(items.at(2).prop('successTurnedOn')).toEqual(false);
expect(items.at(2).prop('errorTurnedOn')).toEqual(false);
expect(items.at(2).prop('startedTurnedOn')).toEqual(true);
expect(NotificationTemplatesAPI.readOptions).toHaveBeenCalled();
expect(MockModelAPI.readNotificationTemplatesSuccess).toHaveBeenCalled();
expect(MockModelAPI.readNotificationTemplatesError).toHaveBeenCalled();
expect(MockModelAPI.readNotificationTemplatesStarted).toHaveBeenCalled();
expect(wrapper.find('NotificationListItem').length).toBe(3);
expect(
wrapper.find('input#notification-1-success-toggle').props().checked
).toBe(true);
expect(
wrapper.find('input#notification-1-error-toggle').props().checked
).toBe(false);
expect(
wrapper.find('input#notification-1-started-toggle').props().checked
).toBe(false);
expect(
wrapper.find('input#notification-2-success-toggle').props().checked
).toBe(false);
expect(
wrapper.find('input#notification-2-error-toggle').props().checked
).toBe(true);
expect(
wrapper.find('input#notification-2-started-toggle').props().checked
).toBe(false);
expect(
wrapper.find('input#notification-3-success-toggle').props().checked
).toBe(false);
expect(
wrapper.find('input#notification-3-error-toggle').props().checked
).toBe(false);
expect(
wrapper.find('input#notification-3-started-toggle').props().checked
).toBe(true);
});
test('should enable success notification', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('successTemplateIds')
).toEqual([1]);
const items = wrapper.find('NotificationListItem');
items
.at(1)
.find('Switch[aria-label="Toggle notification success"]')
.prop('onChange')();
wrapper.find('input#notification-2-success-toggle').props().checked
).toBe(false);
await act(async () => {
wrapper.find('Switch#notification-2-success-toggle').prop('onChange')();
});
wrapper.update();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
2,
'success'
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('successTemplateIds')
).toEqual([1, 2]);
wrapper.find('input#notification-2-success-toggle').props().checked
).toBe(true);
});
test('should enable error notification', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
expect(
wrapper.find('input#notification-1-error-toggle').props().checked
).toBe(false);
await act(async () => {
wrapper.find('Switch#notification-1-error-toggle').prop('onChange')();
});
wrapper.update();
expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual([
2,
]);
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch[aria-label="Toggle notification failure"]')
.prop('onChange')();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
1,
'error'
);
await sleep(0);
wrapper.update();
expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual([
2,
1,
]);
expect(
wrapper.find('input#notification-1-error-toggle').props().checked
).toBe(true);
});
test('should enable start notification', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('startedTemplateIds')
).toEqual([3]);
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch[aria-label="Toggle notification start"]')
.prop('onChange')();
wrapper.find('input#notification-1-started-toggle').props().checked
).toBe(false);
await act(async () => {
wrapper.find('Switch#notification-1-started-toggle').prop('onChange')();
});
wrapper.update();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
1,
'started'
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('startedTemplateIds')
).toEqual([3, 1]);
wrapper.find('input#notification-1-started-toggle').props().checked
).toBe(true);
});
test('should disable success notification', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('successTemplateIds')
).toEqual([1]);
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch[aria-label="Toggle notification success"]')
.prop('onChange')();
wrapper.find('input#notification-1-success-toggle').props().checked
).toBe(true);
await act(async () => {
wrapper.find('Switch#notification-1-success-toggle').prop('onChange')();
});
wrapper.update();
expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith(
1,
1,
'success'
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('successTemplateIds')
).toEqual([]);
wrapper.find('input#notification-1-success-toggle').props().checked
).toBe(false);
});
test('should disable error notification', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
expect(
wrapper.find('input#notification-2-error-toggle').props().checked
).toBe(true);
await act(async () => {
wrapper.find('Switch#notification-2-error-toggle').prop('onChange')();
});
wrapper.update();
expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual([
2,
]);
const items = wrapper.find('NotificationListItem');
items
.at(1)
.find('Switch[aria-label="Toggle notification failure"]')
.prop('onChange')();
expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith(
1,
2,
'error'
);
await sleep(0);
wrapper.update();
expect(wrapper.find('NotificationList').state('errorTemplateIds')).toEqual(
[]
);
expect(
wrapper.find('input#notification-2-error-toggle').props().checked
).toBe(false);
});
test('should disable start notification', async () => {
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('startedTemplateIds')
).toEqual([3]);
const items = wrapper.find('NotificationListItem');
items
.at(2)
.find('Switch[aria-label="Toggle notification start"]')
.prop('onChange')();
wrapper.find('input#notification-3-started-toggle').props().checked
).toBe(true);
await act(async () => {
wrapper.find('Switch#notification-3-started-toggle').prop('onChange')();
});
wrapper.update();
expect(MockModelAPI.disassociateNotificationTemplate).toHaveBeenCalledWith(
1,
3,
'started'
);
await sleep(0);
wrapper.update();
expect(
wrapper.find('NotificationList').state('startedTemplateIds')
).toEqual([]);
wrapper.find('input#notification-3-started-toggle').props().checked
).toBe(false);
});
test('should throw toggle error', async () => {
MockModelAPI.associateNotificationTemplate.mockRejectedValue(
new Error({
@ -284,27 +247,16 @@ describe('<NotificationList />', () => {
},
})
);
const wrapper = mountWithContexts(
<NotificationList id={1} canToggleNotifications apiModel={MockModelAPI} />
);
await sleep(0);
expect(wrapper.find('ErrorDetail').length).toBe(0);
await act(async () => {
wrapper.find('Switch#notification-1-started-toggle').prop('onChange')();
});
wrapper.update();
expect(
wrapper.find('NotificationList').state('startedTemplateIds')
).toEqual([3]);
const items = wrapper.find('NotificationListItem');
items
.at(0)
.find('Switch[aria-label="Toggle notification start"]')
.prop('onChange')();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
1,
'started'
);
await sleep(0);
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
});