1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-27 00:55:06 +03:00

Adds inventory sources notifications list

This commit is contained in:
Alex Corey 2020-06-01 12:07:30 -04:00
parent fb8b90254c
commit 68a8dda869
8 changed files with 196 additions and 27 deletions

View File

@ -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/';

View File

@ -272,4 +272,39 @@ describe('<NotificationList />', () => {
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(
<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')();
expect(MockModelAPI.associateNotificationTemplate).toHaveBeenCalledWith(
1,
1,
'started'
);
await sleep(0);
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
});
});

View File

@ -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) {
</Link>
</DataListCell>,
<DataListCell key="type">
<Label>{i18n._(t`Type `)}</Label>
{typeLabels[notification.notification_type]}
</DataListCell>,
]}

View File

@ -55,7 +55,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
.find('DataListCell')
.at(1)
.find('div');
expect(typeCell.text()).toBe('Slack');
expect(typeCell.text()).toContain('Slack');
});
test('handles start click when toggle is on', () => {

View File

@ -58,6 +58,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
</ForwardRef>
</ForwardRef(Styled(PFDataListCell))>,
<ForwardRef(Styled(PFDataListCell))>
<ForwardRef(styled.b)>
Type
</ForwardRef(styled.b)>
Slack
</ForwardRef(Styled(PFDataListCell))>,
]
@ -167,6 +170,41 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
<div
className="pf-c-data-list__cell sc-bdVaJa kruorc"
>
<styled.b>
<StyledComponent
forwardedComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-htpNat",
"isStatic": false,
"lastClassName": "jyYvCB",
"rules": Array [
"
margin-right: 20px;
",
],
},
"displayName": "styled.b",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-htpNat",
"target": "b",
"toString": [Function],
"warnTooManyClasses": [Function],
"withComponent": [Function],
}
}
forwardedRef={null}
>
<b
className="sc-htpNat jyYvCB"
>
Type
</b>
</StyledComponent>
</styled.b>
Slack
</div>
</PFDataListCell>

View File

@ -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 <ContentError error={error} />;
}
@ -111,6 +134,16 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
>
<InventorySourceEdit source={source} inventory={inventory} />
</Route>
<Route
key="notifications"
path="/inventories/inventory/:id/sources/:sourceId/notifications"
>
<NotificationList
id={Number(match.params.sourceId)}
canToggleNotifications={canToggleNotifications}
apiModel={InventorySourcesAPI}
/>
</Route>
<Route key="not-found" path="*">
<ContentError isNotFound>
<Link to={`${match.url}/details`}>

View File

@ -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('<InventorySource />', () => {
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
<InventorySource
inventory={mockInventory}
me={{ is_system_auditor: false }}
setBreadcrumb={() => {}}
/>
);
});
});
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('<InventorySource />', () => {
});
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(
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
<InventorySource
inventory={mockInventory}
me={{ is_system_auditor: false }}
setBreadcrumb={() => {}}
/>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
@ -71,16 +89,47 @@ describe('<InventorySource />', () => {
});
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(
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />,
<InventorySource
inventory={mockInventory}
setBreadcrumb={() => {}}
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);
});
});

View File

@ -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 }) {
<InventorySourceAdd />
</Route>
<Route path="/inventories/inventory/:id/sources/:sourceId">
<InventorySource inventory={inventory} setBreadcrumb={setBreadcrumb} />
<Config>
{({ me }) => (
<InventorySource
inventory={inventory}
setBreadcrumb={setBreadcrumb}
me={me || {}}
/>
)}
</Config>
</Route>
<Route path="/inventories/:inventoryType/:id/sources">
<InventorySourceList />