diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index 4889a9434cc3..292aebf290cd 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -1,7 +1,8 @@ import Base from '../Base'; +import NotificationsMixin from '../mixins/Notifications.mixin'; import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin'; -class InventorySources extends LaunchUpdateMixin(Base) { +class InventorySources extends LaunchUpdateMixin(NotificationsMixin(Base)) { constructor(http) { super(http); this.baseUrl = '/api/v2/inventory_sources/'; diff --git a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx index c03f2396ce52..ee278ab4bff3 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationList.test.jsx @@ -272,4 +272,39 @@ describe('', () => { wrapper.find('NotificationList').state('startedTemplateIds') ).toEqual([]); }); + test('should throw toggle error', async () => { + MockModelAPI.associateNotificationTemplate.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + 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')(); + expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith( + 1, + 1, + 'started' + ); + await sleep(0); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); }); diff --git a/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx b/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx index d42f536084b1..685711447936 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationListItem.jsx @@ -19,6 +19,9 @@ const DataListAction = styled(_DataListAction)` grid-gap: 16px; grid-template-columns: repeat(3, max-content); `; +const Label = styled.b` + margin-right: 20px; +`; function NotificationListItem(props) { const { @@ -54,6 +57,7 @@ function NotificationListItem(props) { , + {typeLabels[notification.notification_type]} , ]} diff --git a/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx b/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx index d954bb51b2b1..6965b50ff252 100644 --- a/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx +++ b/awx/ui_next/src/components/NotificationList/NotificationListItem.test.jsx @@ -55,7 +55,7 @@ describe('', () => { .find('DataListCell') .at(1) .find('div'); - expect(typeCell.text()).toBe('Slack'); + expect(typeCell.text()).toContain('Slack'); }); test('handles start click when toggle is on', () => { diff --git a/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap b/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap index f99a0c51fdd0..841f4d2060e8 100644 --- a/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap +++ b/awx/ui_next/src/components/NotificationList/__snapshots__/NotificationListItem.test.jsx.snap @@ -58,6 +58,9 @@ exports[` initially renders succe , + + Type + Slack , ] @@ -167,6 +170,41 @@ exports[` initially renders succe
+ + + + Type + + + Slack
diff --git a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx index 96b0b27fffe5..c9a7a730ed28 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.jsx @@ -13,7 +13,11 @@ import { CaretLeftIcon } from '@patternfly/react-icons'; import { CardActions } from '@patternfly/react-core'; import useRequest from '../../../util/useRequest'; -import { InventoriesAPI } from '../../../api'; +import { + InventoriesAPI, + InventorySourcesAPI, + OrganizationsAPI, +} from '../../../api'; import { TabbedCardHeader } from '../../../components/Card'; import CardCloseButton from '../../../components/CardCloseButton'; import ContentError from '../../../components/ContentError'; @@ -21,20 +25,33 @@ import ContentLoading from '../../../components/ContentLoading'; import RoutedTabs from '../../../components/RoutedTabs'; import InventorySourceDetail from '../InventorySourceDetail'; import InventorySourceEdit from '../InventorySourceEdit'; +import NotificationList from '../../../components/NotificationList/NotificationList'; -function InventorySource({ i18n, inventory, setBreadcrumb }) { +function InventorySource({ i18n, inventory, setBreadcrumb, me }) { const location = useLocation(); const match = useRouteMatch('/inventories/inventory/:id/sources/:sourceId'); const sourceListUrl = `/inventories/inventory/${inventory.id}/sources`; - const { result: source, error, isLoading, request: fetchSource } = useRequest( + const { + result: { source, isNotifAdmin }, + error, + isLoading, + request: fetchSource, + } = useRequest( useCallback(async () => { - return InventoriesAPI.readSourceDetail( - inventory.id, - match.params.sourceId - ); + const [inventorySource, notifAdminRes] = await Promise.all([ + InventoriesAPI.readSourceDetail(inventory.id, match.params.sourceId), + OrganizationsAPI.read({ + page_size: 1, + role_level: 'notification_admin_role', + }), + ]); + return { + source: inventorySource, + isNotifAdmin: notifAdminRes.data.results.length > 0, + }; }, [inventory.id, match.params.sourceId]), - null + { source: null, isNotifAdmin: false } ); useEffect(() => { @@ -63,18 +80,24 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) { link: `${match.url}/details`, id: 1, }, - { - name: i18n._(t`Notifications`), - link: `${match.url}/notifications`, - id: 2, - }, { name: i18n._(t`Schedules`), link: `${match.url}/schedules`, - id: 3, + id: 2, }, ]; + const canToggleNotifications = isNotifAdmin; + const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin; + + if (canSeeNotificationsTab) { + tabsArray.push({ + name: i18n._(t`Notifications`), + link: `${match.url}/notifications`, + id: 3, + }); + } + if (error) { return ; } @@ -111,6 +134,16 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) { > + + + diff --git a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx index 7e622ca5d90e..c0e141900aa2 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySource/InventorySource.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; -import { InventoriesAPI } from '../../../api'; +import { InventoriesAPI, OrganizationsAPI } from '../../../api'; import { mountWithContexts, waitForElement, @@ -10,6 +10,9 @@ import mockInventorySource from '../shared/data.inventory_source.json'; import InventorySource from './InventorySource'; jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/InventorySources'); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useRouteMatch: () => ({ @@ -18,10 +21,6 @@ jest.mock('react-router-dom', () => ({ }), })); -InventoriesAPI.readSourceDetail.mockResolvedValue({ - data: { ...mockInventorySource }, -}); - const mockInventory = { id: 2, name: 'Mock Inventory', @@ -34,22 +33,31 @@ describe('', () => { beforeEach(async () => { await act(async () => { wrapper = mountWithContexts( - {}} /> + {}} + /> ); }); }); afterEach(() => { jest.clearAllMocks(); - wrapper.unmount(); }); test('should render expected tabs', () => { + InventoriesAPI.readSourceDetail.mockResolvedValue({ + data: { ...mockInventorySource }, + }); + OrganizationsAPI.read.mockResolvedValue({ + data: { results: [{ id: 1, name: 'isNotifAdmin' }] }, + }); const expectedTabs = [ 'Back to Sources', 'Details', - 'Notifications', 'Schedules', + 'Notifications', ]; wrapper.find('RoutedTabs li').forEach((tab, index) => { expect(tab.text()).toEqual(expectedTabs[index]); @@ -57,10 +65,20 @@ describe('', () => { }); test('should show content error when api throws error on initial render', async () => { + InventoriesAPI.readSourceDetail.mockResolvedValue({ + data: { ...mockInventorySource }, + }); + OrganizationsAPI.read.mockResolvedValue({ + data: { results: [{ id: 1, name: 'isNotifAdmin' }] }, + }); InventoriesAPI.readSourceDetail.mockRejectedValueOnce(new Error()); await act(async () => { wrapper = mountWithContexts( - {}} /> + {}} + /> ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -71,16 +89,47 @@ describe('', () => { }); test('should show content error when user attempts to navigate to erroneous route', async () => { + InventoriesAPI.readSourceDetail.mockResolvedValue({ + data: { ...mockInventorySource }, + }); + OrganizationsAPI.read.mockResolvedValue({ + data: { results: [{ id: 1, name: 'isNotifAdmin' }] }, + }); history = createMemoryHistory({ initialEntries: ['/inventories/inventory/2/sources/1/foobar'], }); await act(async () => { wrapper = mountWithContexts( - {}} />, + {}} + me={{ is_system_auditor: false }} + />, { context: { router: { history } } } ); }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); expect(wrapper.find('ContentError Title').text()).toEqual('Not Found'); }); + + test('should call api', () => { + InventoriesAPI.readSourceDetail.mockResolvedValue({ + data: { ...mockInventorySource }, + }); + OrganizationsAPI.read.mockResolvedValue({ + data: { results: [{ id: 1, name: 'isNotifAdmin' }] }, + }); + expect(InventoriesAPI.readSourceDetail).toBeCalledWith(2, 123); + expect(OrganizationsAPI.read).toBeCalled(); + }); + + test('should not render notifications tab', () => { + InventoriesAPI.readSourceDetail.mockResolvedValue({ + data: { ...mockInventorySource }, + }); + OrganizationsAPI.read.mockResolvedValue({ + data: { results: [] }, + }); + expect(wrapper.find('button[aria-label="Notifications"]').length).toBe(0); + }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx index 982610eb96e7..2e8f1a378505 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySources.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { Switch, Route } from 'react-router-dom'; import InventorySource from '../InventorySource'; +import { Config } from '../../../contexts/Config'; import InventorySourceAdd from '../InventorySourceAdd'; import InventorySourceList from './InventorySourceList'; @@ -11,7 +12,15 @@ function InventorySources({ inventory, setBreadcrumb }) { - + + {({ me }) => ( + + )} +