mirror of
https://github.com/ansible/awx.git
synced 2026-02-21 13:10:11 -03:30
Add inventory source details
This commit is contained in:
@@ -72,6 +72,22 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readSourceDetail(inventoryId, sourceId) {
|
||||||
|
const {
|
||||||
|
data: { results },
|
||||||
|
} = await this.http.get(
|
||||||
|
`${this.baseUrl}${inventoryId}/inventory_sources/?id=${sourceId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(results) && results.length) {
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`How did you get here? Source not found for Inventory ID: ${inventoryId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Inventories;
|
export default Inventories;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class InventorySources extends LaunchUpdateMixin(Base) {
|
|||||||
this.baseUrl = '/api/v2/inventory_sources/';
|
this.baseUrl = '/api/v2/inventory_sources/';
|
||||||
|
|
||||||
this.createSyncStart = this.createSyncStart.bind(this);
|
this.createSyncStart = this.createSyncStart.bind(this);
|
||||||
|
this.destroyGroups = this.destroyGroups.bind(this);
|
||||||
|
this.destroyHosts = this.destroyHosts.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
createSyncStart(sourceId, extraVars) {
|
createSyncStart(sourceId, extraVars) {
|
||||||
@@ -14,5 +16,13 @@ class InventorySources extends LaunchUpdateMixin(Base) {
|
|||||||
extra_vars: extraVars,
|
extra_vars: extraVars,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroyGroups(id) {
|
||||||
|
return this.http.delete(`${this.baseUrl}${id}/groups/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyHosts(id) {
|
||||||
|
return this.http.delete(`${this.baseUrl}${id}/hosts/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default InventorySources;
|
export default InventorySources;
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class Inventories extends Component {
|
|||||||
|
|
||||||
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
|
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
|
||||||
[`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`),
|
[`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`),
|
||||||
|
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
|
||||||
|
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
|
||||||
};
|
};
|
||||||
this.setState({ breadcrumbConfig });
|
this.setState({ breadcrumbConfig });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function Inventory({ i18n, setBreadcrumb }) {
|
|||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
{['edit', 'add', 'groups/', 'hosts/'].some(name =>
|
{['edit', 'add', 'groups/', 'hosts/', 'sources/'].some(name =>
|
||||||
location.pathname.includes(name)
|
location.pathname.includes(name)
|
||||||
) ? null : (
|
) ? null : (
|
||||||
<TabbedCardHeader>
|
<TabbedCardHeader>
|
||||||
@@ -138,7 +138,10 @@ function Inventory({ i18n, setBreadcrumb }) {
|
|||||||
/>
|
/>
|
||||||
</Route>,
|
</Route>,
|
||||||
<Route path="/inventories/inventory/:id/sources" key="sources">
|
<Route path="/inventories/inventory/:id/sources" key="sources">
|
||||||
<InventorySources inventory={inventory} />
|
<InventorySources
|
||||||
|
inventory={inventory}
|
||||||
|
setBreadcrumb={setBreadcrumb}
|
||||||
|
/>
|
||||||
</Route>,
|
</Route>,
|
||||||
<Route
|
<Route
|
||||||
path="/inventories/inventory/:id/completed_jobs"
|
path="/inventories/inventory/:id/completed_jobs"
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
Switch,
|
||||||
|
Route,
|
||||||
|
Redirect,
|
||||||
|
useRouteMatch,
|
||||||
|
useLocation,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import useRequest from '@util/useRequest';
|
||||||
|
|
||||||
|
import { InventoriesAPI } from '@api';
|
||||||
|
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||||
|
import { CardActions } from '@patternfly/react-core';
|
||||||
|
import { TabbedCardHeader } from '@components/Card';
|
||||||
|
import CardCloseButton from '@components/CardCloseButton';
|
||||||
|
import ContentError from '@components/ContentError';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import RoutedTabs from '@components/RoutedTabs';
|
||||||
|
import InventorySourceDetail from '../InventorySourceDetail';
|
||||||
|
|
||||||
|
function InventorySource({ i18n, inventory, setBreadcrumb }) {
|
||||||
|
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(
|
||||||
|
useCallback(async () => {
|
||||||
|
return InventoriesAPI.readSourceDetail(
|
||||||
|
inventory.id,
|
||||||
|
match.params.sourceId
|
||||||
|
);
|
||||||
|
}, [inventory.id, match.params.sourceId]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSource();
|
||||||
|
}, [fetchSource, match.params.sourceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inventory && source) {
|
||||||
|
setBreadcrumb(inventory, source);
|
||||||
|
}
|
||||||
|
}, [inventory, source, setBreadcrumb]);
|
||||||
|
|
||||||
|
const tabsArray = [
|
||||||
|
{
|
||||||
|
name: (
|
||||||
|
<>
|
||||||
|
<CaretLeftIcon />
|
||||||
|
{i18n._(t`Back to Sources`)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
link: `${sourceListUrl}`,
|
||||||
|
id: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Details`),
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ContentError error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{['edit'].some(name => location.pathname.includes(name)) ? null : (
|
||||||
|
<TabbedCardHeader>
|
||||||
|
<RoutedTabs tabsArray={tabsArray} />
|
||||||
|
<CardActions>
|
||||||
|
<CardCloseButton linkTo={sourceListUrl} />
|
||||||
|
</CardActions>
|
||||||
|
</TabbedCardHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <ContentLoading />}
|
||||||
|
|
||||||
|
{!isLoading && source && (
|
||||||
|
<Switch>
|
||||||
|
<Redirect
|
||||||
|
from="/inventories/inventory/:id/sources/:sourceId"
|
||||||
|
to="/inventories/inventory/:id/sources/:sourceId/details"
|
||||||
|
exact
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
key="details"
|
||||||
|
path="/inventories/inventory/:id/sources/:sourceId/details"
|
||||||
|
>
|
||||||
|
<InventorySourceDetail inventorySource={source} />
|
||||||
|
</Route>
|
||||||
|
<Route key="not-found" path="*">
|
||||||
|
<ContentError isNotFound>
|
||||||
|
<Link to={`${match.url}/details`}>
|
||||||
|
{i18n._(`View inventory source details`)}
|
||||||
|
</Link>
|
||||||
|
</ContentError>
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(InventorySource);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { InventoriesAPI } from '@api';
|
||||||
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
import mockInventorySource from '../shared/data.inventory_source.json';
|
||||||
|
import InventorySource from './InventorySource';
|
||||||
|
|
||||||
|
jest.mock('@api/models/Inventories');
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useRouteMatch: () => ({
|
||||||
|
url: '/inventories/inventory/2/sources/123',
|
||||||
|
params: { id: 2, sourceId: 123 },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
InventoriesAPI.readSourceDetail.mockResolvedValue({
|
||||||
|
data: { ...mockInventorySource },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockInventory = {
|
||||||
|
id: 2,
|
||||||
|
name: 'Mock Inventory',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<InventorySource />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render expected tabs', () => {
|
||||||
|
const expectedTabs = [
|
||||||
|
'Back to Sources',
|
||||||
|
'Details',
|
||||||
|
'Notifications',
|
||||||
|
'Schedules',
|
||||||
|
];
|
||||||
|
wrapper.find('RoutedTabs li').forEach((tab, index) => {
|
||||||
|
expect(tab.text()).toEqual(expectedTabs[index]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show content error when api throws error on initial render', async () => {
|
||||||
|
InventoriesAPI.readSourceDetail.mockRejectedValueOnce(new Error());
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
|
expect(wrapper.find('ContentError Title').text()).toEqual(
|
||||||
|
'Something went wrong...'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/inventories/inventory/2/sources/1/foobar'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySource inventory={mockInventory} setBreadcrumb={() => {}} />,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||||
|
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventorySource';
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
ChipGroup,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import AlertModal from '@components/AlertModal';
|
||||||
|
import { CardBody, CardActionsRow } from '@components/Card';
|
||||||
|
import { VariablesDetail } from '@components/CodeMirrorInput';
|
||||||
|
import CredentialChip from '@components/CredentialChip';
|
||||||
|
import DeleteButton from '@components/DeleteButton';
|
||||||
|
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
|
||||||
|
import ErrorDetail from '@components/ErrorDetail';
|
||||||
|
import { InventorySourcesAPI } from '@api';
|
||||||
|
|
||||||
|
function InventorySourceDetail({ inventorySource, i18n }) {
|
||||||
|
const {
|
||||||
|
created,
|
||||||
|
custom_virtualenv,
|
||||||
|
description,
|
||||||
|
group_by,
|
||||||
|
id,
|
||||||
|
instance_filters,
|
||||||
|
modified,
|
||||||
|
name,
|
||||||
|
overwrite,
|
||||||
|
overwrite_vars,
|
||||||
|
source,
|
||||||
|
source_path,
|
||||||
|
source_regions,
|
||||||
|
source_vars,
|
||||||
|
update_cache_timeout,
|
||||||
|
update_on_launch,
|
||||||
|
update_on_project_update,
|
||||||
|
verbosity,
|
||||||
|
summary_fields: {
|
||||||
|
created_by,
|
||||||
|
credentials,
|
||||||
|
inventory,
|
||||||
|
modified_by,
|
||||||
|
organization,
|
||||||
|
source_project,
|
||||||
|
source_script,
|
||||||
|
user_capabilities,
|
||||||
|
},
|
||||||
|
} = inventorySource;
|
||||||
|
const [deletionError, setDeletionError] = useState(false);
|
||||||
|
const history = useHistory();
|
||||||
|
const isMounted = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true;
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
InventorySourcesAPI.destroyHosts(id),
|
||||||
|
InventorySourcesAPI.destroyGroups(id),
|
||||||
|
InventorySourcesAPI.destroy(id),
|
||||||
|
]);
|
||||||
|
history.push(`/inventories/inventory/${inventory.id}/sources`);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMounted.current) {
|
||||||
|
setDeletionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERBOSITY = {
|
||||||
|
0: i18n._(t`0 (Warning)`),
|
||||||
|
1: i18n._(t`1 (Info)`),
|
||||||
|
2: i18n._(t`2 (Debug)`),
|
||||||
|
};
|
||||||
|
|
||||||
|
let optionsList = '';
|
||||||
|
if (
|
||||||
|
overwrite ||
|
||||||
|
overwrite_vars ||
|
||||||
|
update_on_launch ||
|
||||||
|
update_on_project_update
|
||||||
|
) {
|
||||||
|
optionsList = (
|
||||||
|
<List>
|
||||||
|
{overwrite && <ListItem>{i18n._(t`Overwrite`)}</ListItem>}
|
||||||
|
{overwrite_vars && (
|
||||||
|
<ListItem>{i18n._(t`Overwrite variables`)}</ListItem>
|
||||||
|
)}
|
||||||
|
{update_on_launch && <ListItem>{i18n._(t`Update on launch`)}</ListItem>}
|
||||||
|
{update_on_project_update && (
|
||||||
|
<ListItem>{i18n._(t`Update on project update`)}</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<DetailList>
|
||||||
|
<Detail label={i18n._(t`Name`)} value={name} />
|
||||||
|
<Detail label={i18n._(t`Description`)} value={description} />
|
||||||
|
<Detail label={i18n._(t`Source`)} value={source} />
|
||||||
|
{organization && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Organization`)}
|
||||||
|
value={
|
||||||
|
<Link to={`/organizations/${organization.id}/details`}>
|
||||||
|
{organization.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Ansible environment`)}
|
||||||
|
value={custom_virtualenv}
|
||||||
|
/>
|
||||||
|
{source_project && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Project`)}
|
||||||
|
value={
|
||||||
|
<Link to={`/projects/${source_project.id}/details`}>
|
||||||
|
{source_project.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Detail label={i18n._(t`Inventory file`)} value={source_path} />
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Custom inventory script`)}
|
||||||
|
value={source_script?.name}
|
||||||
|
/>
|
||||||
|
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[verbosity]} />
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Cache timeout`)}
|
||||||
|
value={`${update_cache_timeout} ${i18n._(t`seconds`)}`}
|
||||||
|
/>
|
||||||
|
{credentials?.length > 0 && (
|
||||||
|
<Detail
|
||||||
|
fullWidth
|
||||||
|
label={i18n._(t`Credential`)}
|
||||||
|
value={credentials.map(cred => (
|
||||||
|
<CredentialChip key={cred?.id} credential={cred} isReadOnly />
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{source_regions && (
|
||||||
|
<Detail
|
||||||
|
fullWidth
|
||||||
|
label={i18n._(t`Regions`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup numChips={5}>
|
||||||
|
{source_regions.split(',').map(region => (
|
||||||
|
<Chip key={region} isReadOnly>
|
||||||
|
{region}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{instance_filters && (
|
||||||
|
<Detail
|
||||||
|
fullWidth
|
||||||
|
label={i18n._(t`Instance filters`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup numChips={5}>
|
||||||
|
{instance_filters.split(',').map(filter => (
|
||||||
|
<Chip key={filter} isReadOnly>
|
||||||
|
{filter}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{group_by && (
|
||||||
|
<Detail
|
||||||
|
fullWidth
|
||||||
|
label={i18n._(t`Only group by`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup numChips={5}>
|
||||||
|
{group_by.split(',').map(group => (
|
||||||
|
<Chip key={group} isReadOnly>
|
||||||
|
{group}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{optionsList && (
|
||||||
|
<Detail fullWidth label={i18n._(t`Options`)} value={optionsList} />
|
||||||
|
)}
|
||||||
|
{source_vars && (
|
||||||
|
<VariablesDetail
|
||||||
|
label={i18n._(t`Source variables`)}
|
||||||
|
rows={4}
|
||||||
|
value={source_vars}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<UserDateDetail
|
||||||
|
date={created}
|
||||||
|
label={i18n._(t`Created`)}
|
||||||
|
user={created_by}
|
||||||
|
/>
|
||||||
|
<UserDateDetail
|
||||||
|
date={modified}
|
||||||
|
label={i18n._(t`Last modified`)}
|
||||||
|
user={modified_by}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
|
<CardActionsRow>
|
||||||
|
{user_capabilities?.edit && (
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
aria-label={i18n._(t`edit`)}
|
||||||
|
to={`/inventories/inventory/${inventory.id}/source/${id}/edit`}
|
||||||
|
>
|
||||||
|
{i18n._(t`Edit`)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{user_capabilities?.delete && (
|
||||||
|
<DeleteButton
|
||||||
|
name={name}
|
||||||
|
modalTitle={i18n._(t`Delete inventory source`)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
>
|
||||||
|
{i18n._(t`Delete`)}
|
||||||
|
</DeleteButton>
|
||||||
|
)}
|
||||||
|
</CardActionsRow>
|
||||||
|
{deletionError && (
|
||||||
|
<AlertModal
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
isOpen={deletionError}
|
||||||
|
onClose={() => setDeletionError(false)}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to delete inventory source ${name}.`)}
|
||||||
|
<ErrorDetail error={deletionError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default withI18n()(InventorySourceDetail);
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
import InventorySourceDetail from './InventorySourceDetail';
|
||||||
|
import mockInvSource from '../shared/data.inventory_source.json';
|
||||||
|
import { InventorySourcesAPI } from '@api';
|
||||||
|
|
||||||
|
jest.mock('@api/models/InventorySources');
|
||||||
|
|
||||||
|
function assertDetail(wrapper, label, value) {
|
||||||
|
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
|
||||||
|
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InventorySourceDetail', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render expected details', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySourceDetail inventorySource={mockInvSource} />
|
||||||
|
);
|
||||||
|
expect(wrapper.find('InventorySourceDetail')).toHaveLength(1);
|
||||||
|
assertDetail(wrapper, 'Name', 'mock inv source');
|
||||||
|
assertDetail(wrapper, 'Description', 'mock description');
|
||||||
|
assertDetail(wrapper, 'Source', 'scm');
|
||||||
|
assertDetail(wrapper, 'Organization', 'Mock Org');
|
||||||
|
assertDetail(wrapper, 'Ansible environment', '/venv/custom');
|
||||||
|
assertDetail(wrapper, 'Project', 'Mock Project');
|
||||||
|
assertDetail(wrapper, 'Inventory file', 'foo');
|
||||||
|
assertDetail(wrapper, 'Custom inventory script', 'Mock Script');
|
||||||
|
assertDetail(wrapper, 'Verbosity', '2 (Debug)');
|
||||||
|
assertDetail(wrapper, 'Cache timeout', '2 seconds');
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Detail[label="Regions"]')
|
||||||
|
.containsAllMatchingElements([
|
||||||
|
<span>us-east-1</span>,
|
||||||
|
<span>us-east-2</span>,
|
||||||
|
])
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Detail[label="Instance filters"]')
|
||||||
|
.containsAllMatchingElements([
|
||||||
|
<span>filter1</span>,
|
||||||
|
<span>filter2</span>,
|
||||||
|
<span>filter3</span>,
|
||||||
|
])
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Detail[label="Only group by"]')
|
||||||
|
.containsAllMatchingElements([
|
||||||
|
<span>group1</span>,
|
||||||
|
<span>group2</span>,
|
||||||
|
<span>group3</span>,
|
||||||
|
])
|
||||||
|
).toEqual(true);
|
||||||
|
expect(wrapper.find('CredentialChip').text()).toBe('Cloud: mock cred');
|
||||||
|
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
|
||||||
|
'---\nfoo: bar'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Detail[label="Options"]')
|
||||||
|
.containsAllMatchingElements([
|
||||||
|
<li>Overwrite</li>,
|
||||||
|
<li>Overwrite variables</li>,
|
||||||
|
<li>Update on launch</li>,
|
||||||
|
<li>Update on project update</li>,
|
||||||
|
])
|
||||||
|
).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show edit and delete button for users with permissions', () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySourceDetail inventorySource={mockInvSource} />
|
||||||
|
);
|
||||||
|
const editButton = wrapper.find('Button[aria-label="edit"]');
|
||||||
|
expect(editButton.text()).toEqual('Edit');
|
||||||
|
expect(editButton.prop('to')).toBe(
|
||||||
|
'/inventories/inventory/2/source/123/edit'
|
||||||
|
);
|
||||||
|
expect(wrapper.find('DeleteButton')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide edit and delete button for users without permissions', () => {
|
||||||
|
const userCapabilities = {
|
||||||
|
edit: false,
|
||||||
|
delete: false,
|
||||||
|
};
|
||||||
|
const invSource = {
|
||||||
|
...mockInvSource,
|
||||||
|
summary_fields: { ...userCapabilities },
|
||||||
|
};
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySourceDetail inventorySource={invSource} />
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Button[aria-label="edit"]')).toHaveLength(0);
|
||||||
|
expect(wrapper.find('DeleteButton')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expected api call is made for delete', async () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/inventories/inventory/2/sources/123/details'],
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySourceDetail inventorySource={mockInvSource} />,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual(
|
||||||
|
'/inventories/inventory/2/sources/123/details'
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DeleteButton').invoke('onConfirm')();
|
||||||
|
});
|
||||||
|
expect(InventorySourcesAPI.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(InventorySourcesAPI.destroyHosts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(InventorySourcesAPI.destroyGroups).toHaveBeenCalledTimes(1);
|
||||||
|
expect(history.location.pathname).toEqual(
|
||||||
|
'/inventories/inventory/2/sources'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error dialog shown for failed deletion', async () => {
|
||||||
|
InventorySourcesAPI.destroy.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySourceDetail inventorySource={mockInvSource} />
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Modal[title="Error!"]')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('DeleteButton').invoke('onConfirm')();
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal[title="Error!"]',
|
||||||
|
el => el.length === 1
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Modal[title="Error!"]').invoke('onClose')();
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal[title="Error!"]',
|
||||||
|
el => el.length === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InventorySourceDetail';
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
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 InventorySourceAdd from '../InventorySourceAdd';
|
import InventorySourceAdd from '../InventorySourceAdd';
|
||||||
import InventorySourceList from './InventorySourceList';
|
import InventorySourceList from './InventorySourceList';
|
||||||
|
|
||||||
function InventorySources() {
|
function InventorySources({ inventory, setBreadcrumb }) {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route key="add" path="/inventories/inventory/:id/sources/add">
|
<Route key="add" path="/inventories/inventory/:id/sources/add">
|
||||||
<InventorySourceAdd />
|
<InventorySourceAdd />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/inventories/inventory/:id/sources/:sourceId">
|
||||||
|
<InventorySource inventory={inventory} setBreadcrumb={setBreadcrumb} />
|
||||||
|
</Route>
|
||||||
<Route path="/inventories/:inventoryType/:id/sources">
|
<Route path="/inventories/:inventoryType/:id/sources">
|
||||||
<InventorySourceList />
|
<InventorySourceList />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"id":123,
|
||||||
|
"type":"inventory_source",
|
||||||
|
"url":"/api/v2/inventory_sources/123/",
|
||||||
|
"related":{
|
||||||
|
"named_url":"/api/v2/inventory_sources/src++Demo Inventory++Default/",
|
||||||
|
"created_by":"/api/v2/users/1/",
|
||||||
|
"modified_by":"/api/v2/users/1/",
|
||||||
|
"update":"/api/v2/inventory_sources/123/update/",
|
||||||
|
"inventory_updates":"/api/v2/inventory_sources/123/inventory_updates/",
|
||||||
|
"schedules":"/api/v2/inventory_sources/123/schedules/",
|
||||||
|
"activity_stream":"/api/v2/inventory_sources/123/activity_stream/",
|
||||||
|
"hosts":"/api/v2/inventory_sources/123/hosts/",
|
||||||
|
"groups":"/api/v2/inventory_sources/123/groups/",
|
||||||
|
"notification_templates_started":"/api/v2/inventory_sources/123/notification_templates_started/",
|
||||||
|
"notification_templates_success":"/api/v2/inventory_sources/123/notification_templates_success/",
|
||||||
|
"notification_templates_error":"/api/v2/inventory_sources/123/notification_templates_error/",
|
||||||
|
"inventory":"/api/v2/inventories/1/",
|
||||||
|
"source_project":"/api/v2/projects/8/",
|
||||||
|
"credentials":"/api/v2/inventory_sources/123/credentials/"
|
||||||
|
},
|
||||||
|
"summary_fields":{
|
||||||
|
"organization":{
|
||||||
|
"id":1,
|
||||||
|
"name":"Mock Org",
|
||||||
|
"description":""
|
||||||
|
},
|
||||||
|
"inventory":{
|
||||||
|
"id":2,
|
||||||
|
"name":"Mock Inventory",
|
||||||
|
"description":"",
|
||||||
|
"has_active_failures":false,
|
||||||
|
"total_hosts":1,
|
||||||
|
"hosts_with_active_failures":0,
|
||||||
|
"total_groups":2,
|
||||||
|
"has_inventory_sources":true,
|
||||||
|
"total_inventory_sources":5,
|
||||||
|
"inventory_sources_with_failures":0,
|
||||||
|
"organization_id":1,
|
||||||
|
"kind":""
|
||||||
|
},
|
||||||
|
"source_project":{
|
||||||
|
"id":8,
|
||||||
|
"name":"Mock Project",
|
||||||
|
"description":"",
|
||||||
|
"status":"never updated",
|
||||||
|
"scm_type":"git"
|
||||||
|
},
|
||||||
|
"source_script": {
|
||||||
|
"name": "Mock Script",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"created_by":{
|
||||||
|
"id":1,
|
||||||
|
"username":"admin",
|
||||||
|
"first_name":"",
|
||||||
|
"last_name":""
|
||||||
|
},
|
||||||
|
"modified_by":{
|
||||||
|
"id":1,
|
||||||
|
"username":"admin",
|
||||||
|
"first_name":"",
|
||||||
|
"last_name":""
|
||||||
|
},
|
||||||
|
"user_capabilities":{
|
||||||
|
"edit":true,
|
||||||
|
"delete":true,
|
||||||
|
"start":true,
|
||||||
|
"schedule":true
|
||||||
|
},
|
||||||
|
"credential": {
|
||||||
|
"id": 8,
|
||||||
|
"name": "mock cred",
|
||||||
|
"description": "",
|
||||||
|
"kind": "vmware",
|
||||||
|
"cloud": true,
|
||||||
|
"credential_type_id": 7
|
||||||
|
},
|
||||||
|
"credentials":[
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "mock cred",
|
||||||
|
"description": "",
|
||||||
|
"kind": "vmware",
|
||||||
|
"cloud": true,
|
||||||
|
"credential_type_id": 7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"created":"2020-04-02T18:59:08.474167Z",
|
||||||
|
"modified":"2020-04-02T19:52:23.924252Z",
|
||||||
|
"name":"mock inv source",
|
||||||
|
"description":"mock description",
|
||||||
|
"source":"scm",
|
||||||
|
"source_path": "foo",
|
||||||
|
"source_script": "Mock Script",
|
||||||
|
"source_vars":"---\nfoo: bar",
|
||||||
|
"credential": 8,
|
||||||
|
"source_regions": "us-east-1,us-east-2",
|
||||||
|
"instance_filters": "filter1,filter2,filter3",
|
||||||
|
"group_by": "group1,group2,group3",
|
||||||
|
"overwrite":true,
|
||||||
|
"overwrite_vars":true,
|
||||||
|
"custom_virtualenv":"/venv/custom",
|
||||||
|
"timeout":0,
|
||||||
|
"verbosity":2,
|
||||||
|
"last_job_run":null,
|
||||||
|
"last_job_failed":false,
|
||||||
|
"next_job_run":null,
|
||||||
|
"status":"never updated",
|
||||||
|
"inventory":1,
|
||||||
|
"update_on_launch":true,
|
||||||
|
"update_cache_timeout":2,
|
||||||
|
"source_project":8,
|
||||||
|
"update_on_project_update":true,
|
||||||
|
"last_update_failed": true,
|
||||||
|
"last_updated":null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user