mirror of
https://github.com/ansible/awx.git
synced 2026-03-22 03:17:39 -02:30
Adds inventory sources notifications list
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
|
import NotificationsMixin from '../mixins/Notifications.mixin';
|
||||||
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
||||||
|
|
||||||
class InventorySources extends LaunchUpdateMixin(Base) {
|
class InventorySources extends LaunchUpdateMixin(NotificationsMixin(Base)) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/inventory_sources/';
|
this.baseUrl = '/api/v2/inventory_sources/';
|
||||||
|
|||||||
@@ -272,4 +272,39 @@ describe('<NotificationList />', () => {
|
|||||||
wrapper.find('NotificationList').state('startedTemplateIds')
|
wrapper.find('NotificationList').state('startedTemplateIds')
|
||||||
).toEqual([]);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const DataListAction = styled(_DataListAction)`
|
|||||||
grid-gap: 16px;
|
grid-gap: 16px;
|
||||||
grid-template-columns: repeat(3, max-content);
|
grid-template-columns: repeat(3, max-content);
|
||||||
`;
|
`;
|
||||||
|
const Label = styled.b`
|
||||||
|
margin-right: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
function NotificationListItem(props) {
|
function NotificationListItem(props) {
|
||||||
const {
|
const {
|
||||||
@@ -54,6 +57,7 @@ function NotificationListItem(props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
<DataListCell key="type">
|
<DataListCell key="type">
|
||||||
|
<Label>{i18n._(t`Type `)}</Label>
|
||||||
{typeLabels[notification.notification_type]}
|
{typeLabels[notification.notification_type]}
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ describe('<NotificationListItem canToggleNotifications />', () => {
|
|||||||
.find('DataListCell')
|
.find('DataListCell')
|
||||||
.at(1)
|
.at(1)
|
||||||
.find('div');
|
.find('div');
|
||||||
expect(typeCell.text()).toBe('Slack');
|
expect(typeCell.text()).toContain('Slack');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles start click when toggle is on', () => {
|
test('handles start click when toggle is on', () => {
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
|||||||
</ForwardRef>
|
</ForwardRef>
|
||||||
</ForwardRef(Styled(PFDataListCell))>,
|
</ForwardRef(Styled(PFDataListCell))>,
|
||||||
<ForwardRef(Styled(PFDataListCell))>
|
<ForwardRef(Styled(PFDataListCell))>
|
||||||
|
<ForwardRef(styled.b)>
|
||||||
|
Type
|
||||||
|
</ForwardRef(styled.b)>
|
||||||
Slack
|
Slack
|
||||||
</ForwardRef(Styled(PFDataListCell))>,
|
</ForwardRef(Styled(PFDataListCell))>,
|
||||||
]
|
]
|
||||||
@@ -167,6 +170,41 @@ exports[`<NotificationListItem canToggleNotifications /> initially renders succe
|
|||||||
<div
|
<div
|
||||||
className="pf-c-data-list__cell sc-bdVaJa kruorc"
|
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
|
Slack
|
||||||
</div>
|
</div>
|
||||||
</PFDataListCell>
|
</PFDataListCell>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
|
|||||||
import { CardActions } from '@patternfly/react-core';
|
import { CardActions } from '@patternfly/react-core';
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest from '../../../util/useRequest';
|
||||||
|
|
||||||
import { InventoriesAPI } from '../../../api';
|
import {
|
||||||
|
InventoriesAPI,
|
||||||
|
InventorySourcesAPI,
|
||||||
|
OrganizationsAPI,
|
||||||
|
} from '../../../api';
|
||||||
import { TabbedCardHeader } from '../../../components/Card';
|
import { TabbedCardHeader } from '../../../components/Card';
|
||||||
import CardCloseButton from '../../../components/CardCloseButton';
|
import CardCloseButton from '../../../components/CardCloseButton';
|
||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
@@ -21,20 +25,33 @@ import ContentLoading from '../../../components/ContentLoading';
|
|||||||
import RoutedTabs from '../../../components/RoutedTabs';
|
import RoutedTabs from '../../../components/RoutedTabs';
|
||||||
import InventorySourceDetail from '../InventorySourceDetail';
|
import InventorySourceDetail from '../InventorySourceDetail';
|
||||||
import InventorySourceEdit from '../InventorySourceEdit';
|
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 location = useLocation();
|
||||||
const match = useRouteMatch('/inventories/inventory/:id/sources/:sourceId');
|
const match = useRouteMatch('/inventories/inventory/:id/sources/:sourceId');
|
||||||
const sourceListUrl = `/inventories/inventory/${inventory.id}/sources`;
|
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 () => {
|
useCallback(async () => {
|
||||||
return InventoriesAPI.readSourceDetail(
|
const [inventorySource, notifAdminRes] = await Promise.all([
|
||||||
inventory.id,
|
InventoriesAPI.readSourceDetail(inventory.id, match.params.sourceId),
|
||||||
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]),
|
}, [inventory.id, match.params.sourceId]),
|
||||||
null
|
{ source: null, isNotifAdmin: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,18 +80,24 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
|||||||
link: `${match.url}/details`,
|
link: `${match.url}/details`,
|
||||||
id: 1,
|
id: 1,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: i18n._(t`Notifications`),
|
|
||||||
link: `${match.url}/notifications`,
|
|
||||||
id: 2,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: i18n._(t`Schedules`),
|
name: i18n._(t`Schedules`),
|
||||||
link: `${match.url}/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) {
|
if (error) {
|
||||||
return <ContentError error={error} />;
|
return <ContentError error={error} />;
|
||||||
}
|
}
|
||||||
@@ -111,6 +134,16 @@ function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
|||||||
>
|
>
|
||||||
<InventorySourceEdit source={source} inventory={inventory} />
|
<InventorySourceEdit source={source} inventory={inventory} />
|
||||||
</Route>
|
</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="*">
|
<Route key="not-found" path="*">
|
||||||
<ContentError isNotFound>
|
<ContentError isNotFound>
|
||||||
<Link to={`${match.url}/details`}>
|
<Link to={`${match.url}/details`}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI, OrganizationsAPI } from '../../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -10,6 +10,9 @@ import mockInventorySource from '../shared/data.inventory_source.json';
|
|||||||
import InventorySource from './InventorySource';
|
import InventorySource from './InventorySource';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Inventories');
|
jest.mock('../../../api/models/Inventories');
|
||||||
|
jest.mock('../../../api/models/Organizations');
|
||||||
|
jest.mock('../../../api/models/InventorySources');
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
jest.mock('react-router-dom', () => ({
|
||||||
...jest.requireActual('react-router-dom'),
|
...jest.requireActual('react-router-dom'),
|
||||||
useRouteMatch: () => ({
|
useRouteMatch: () => ({
|
||||||
@@ -18,10 +21,6 @@ jest.mock('react-router-dom', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
|
||||||
data: { ...mockInventorySource },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockInventory = {
|
const mockInventory = {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Mock Inventory',
|
name: 'Mock Inventory',
|
||||||
@@ -34,22 +33,31 @@ describe('<InventorySource />', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
<InventorySource
|
||||||
|
inventory={mockInventory}
|
||||||
|
me={{ is_system_auditor: false }}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render expected tabs', () => {
|
test('should render expected tabs', () => {
|
||||||
|
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||||
|
data: { ...mockInventorySource },
|
||||||
|
});
|
||||||
|
OrganizationsAPI.read.mockResolvedValue({
|
||||||
|
data: { results: [{ id: 1, name: 'isNotifAdmin' }] },
|
||||||
|
});
|
||||||
const expectedTabs = [
|
const expectedTabs = [
|
||||||
'Back to Sources',
|
'Back to Sources',
|
||||||
'Details',
|
'Details',
|
||||||
'Notifications',
|
|
||||||
'Schedules',
|
'Schedules',
|
||||||
|
'Notifications',
|
||||||
];
|
];
|
||||||
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||||
expect(tab.text()).toEqual(expectedTabs[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 () => {
|
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());
|
InventoriesAPI.readSourceDetail.mockRejectedValueOnce(new Error());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
<InventorySource
|
||||||
|
inventory={mockInventory}
|
||||||
|
me={{ is_system_auditor: false }}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
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 () => {
|
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({
|
history = createMemoryHistory({
|
||||||
initialEntries: ['/inventories/inventory/2/sources/1/foobar'],
|
initialEntries: ['/inventories/inventory/2/sources/1/foobar'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />,
|
<InventorySource
|
||||||
|
inventory={mockInventory}
|
||||||
|
setBreadcrumb={() => {}}
|
||||||
|
me={{ is_system_auditor: false }}
|
||||||
|
/>,
|
||||||
{ context: { router: { history } } }
|
{ context: { router: { history } } }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Switch, Route } from 'react-router-dom';
|
import { Switch, Route } from 'react-router-dom';
|
||||||
import InventorySource from '../InventorySource';
|
import InventorySource from '../InventorySource';
|
||||||
|
import { Config } from '../../../contexts/Config';
|
||||||
import InventorySourceAdd from '../InventorySourceAdd';
|
import InventorySourceAdd from '../InventorySourceAdd';
|
||||||
import InventorySourceList from './InventorySourceList';
|
import InventorySourceList from './InventorySourceList';
|
||||||
|
|
||||||
@@ -11,7 +12,15 @@ function InventorySources({ inventory, setBreadcrumb }) {
|
|||||||
<InventorySourceAdd />
|
<InventorySourceAdd />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/inventories/inventory/:id/sources/:sourceId">
|
<Route path="/inventories/inventory/:id/sources/:sourceId">
|
||||||
<InventorySource inventory={inventory} setBreadcrumb={setBreadcrumb} />
|
<Config>
|
||||||
|
{({ me }) => (
|
||||||
|
<InventorySource
|
||||||
|
inventory={inventory}
|
||||||
|
setBreadcrumb={setBreadcrumb}
|
||||||
|
me={me || {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Config>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/inventories/:inventoryType/:id/sources">
|
<Route path="/inventories/:inventoryType/:id/sources">
|
||||||
<InventorySourceList />
|
<InventorySourceList />
|
||||||
|
|||||||
Reference in New Issue
Block a user