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 }) => (
+
+ )}
+