Add inventory source details

This commit is contained in:
Marliana Lara 2020-05-08 15:02:06 -04:00
parent ff573e06b3
commit 4e8bbdaae7
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
12 changed files with 776 additions and 3 deletions

View File

@ -72,6 +72,22 @@ class Inventories extends InstanceGroupsMixin(Base) {
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;

View File

@ -7,6 +7,8 @@ class InventorySources extends LaunchUpdateMixin(Base) {
this.baseUrl = '/api/v2/inventory_sources/';
this.createSyncStart = this.createSyncStart.bind(this);
this.destroyGroups = this.destroyGroups.bind(this);
this.destroyHosts = this.destroyHosts.bind(this);
}
createSyncStart(sourceId, extraVars) {
@ -14,5 +16,13 @@ class InventorySources extends LaunchUpdateMixin(Base) {
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;

View File

@ -77,6 +77,8 @@ class Inventories extends Component {
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
[`${inventorySourcesPath}/add`]: i18n._(t`Create New Source`),
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
};
this.setState({ breadcrumbConfig });
};

View File

@ -93,7 +93,7 @@ function Inventory({ i18n, setBreadcrumb }) {
return (
<PageSection>
<Card>
{['edit', 'add', 'groups/', 'hosts/'].some(name =>
{['edit', 'add', 'groups/', 'hosts/', 'sources/'].some(name =>
location.pathname.includes(name)
) ? null : (
<TabbedCardHeader>
@ -138,7 +138,10 @@ function Inventory({ i18n, setBreadcrumb }) {
/>
</Route>,
<Route path="/inventories/inventory/:id/sources" key="sources">
<InventorySources inventory={inventory} />
<InventorySources
inventory={inventory}
setBreadcrumb={setBreadcrumb}
/>
</Route>,
<Route
path="/inventories/inventory/:id/completed_jobs"

View File

@ -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);

View File

@ -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');
});
});

View File

@ -0,0 +1 @@
export { default } from './InventorySource';

View File

@ -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);

View File

@ -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
);
});
});

View File

@ -0,0 +1 @@
export { default } from './InventorySourceDetail';

View File

@ -1,14 +1,18 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import InventorySource from '../InventorySource';
import InventorySourceAdd from '../InventorySourceAdd';
import InventorySourceList from './InventorySourceList';
function InventorySources() {
function InventorySources({ inventory, setBreadcrumb }) {
return (
<Switch>
<Route key="add" path="/inventories/inventory/:id/sources/add">
<InventorySourceAdd />
</Route>
<Route path="/inventories/inventory/:id/sources/:sourceId">
<InventorySource inventory={inventory} setBreadcrumb={setBreadcrumb} />
</Route>
<Route path="/inventories/:inventoryType/:id/sources">
<InventorySourceList />
</Route>

View File

@ -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
}