diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.jsx index 0854c16f99..cb2b88d168 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.jsx @@ -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 ( - - ( - - )} - /> + return ( + <> + ( + + )} + /> + {toggleError && ( setToggleError(null)} > {i18n._(t`Failed to toggle notification.`)} - - ); - } + )} + + ); } 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); diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx index ee278ab4bf..071eb45e9f 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx @@ -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('', () => { + let wrapper; const data = { count: 2, results: [ @@ -58,220 +56,185 @@ describe('', () => { }, }); - 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( + + ); }); + wrapper.update(); }); afterEach(() => { - jest.clearAllMocks(); + wrapper.unmount(); }); - test('initially renders succesfully', async () => { - const wrapper = mountWithContexts( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - 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( - - ); - 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('', () => { }, }) ); - const wrapper = mountWithContexts( - - ); - 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); }); });