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

Add smart inv detail

This commit is contained in:
Marliana Lara 2020-06-26 13:58:12 -04:00
parent 8ea31d8cdd
commit b5bbfaab11
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
11 changed files with 667 additions and 313 deletions

View File

@ -64,7 +64,7 @@ function InventoryScriptLookup({
fieldId="inventory-script" fieldId="inventory-script"
helperTextInvalid={helperTextInvalid} helperTextInvalid={helperTextInvalid}
isRequired={required} isRequired={required}
isValid={isValid} validated={isValid ? 'default' : 'error'}
label={i18n._(t`Inventory script`)} label={i18n._(t`Inventory script`)}
> >
<Lookup <Lookup

View File

@ -72,10 +72,12 @@ function HostDetail({ i18n, host }) {
<HostToggle host={host} css="padding-bottom: 40px" /> <HostToggle host={host} css="padding-bottom: 40px" />
<DetailList gutter="sm"> <DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} dataCy="host-name" /> <Detail label={i18n._(t`Name`)} value={name} dataCy="host-name" />
<Detail {recentPlaybookJobs?.length > 0 && (
label={i18n._(t`Activity`)} <Detail
value={<Sparkline jobs={recentPlaybookJobs} />} label={i18n._(t`Activity`)}
/> value={<Sparkline jobs={recentPlaybookJobs} />}
/>
)}
<Detail label={i18n._(t`Description`)} value={description} /> <Detail label={i18n._(t`Description`)} value={description} />
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react'; import React, { useState, useCallback } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Route, withRouter, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
@ -11,131 +11,116 @@ import SmartInventory from './SmartInventory';
import InventoryAdd from './InventoryAdd'; import InventoryAdd from './InventoryAdd';
import SmartInventoryAdd from './SmartInventoryAdd'; import SmartInventoryAdd from './SmartInventoryAdd';
class Inventories extends Component { function Inventories({ i18n }) {
constructor(props) { const [breadcrumbConfig, setBreadcrumbConfig] = useState({
super(props); '/inventories': i18n._(t`Inventories`),
const { i18n } = this.props; '/inventories/inventory/add': i18n._(t`Create new inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
});
this.state = { const buildBreadcrumbConfig = useCallback(
breadcrumbConfig: { (inventory, nested, schedule) => {
if (!inventory) {
return;
}
const inventoryKind =
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory';
const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`;
const inventoryHostsPath = `${inventoryPath}/hosts`;
const inventoryGroupsPath = `${inventoryPath}/groups`;
const inventorySourcesPath = `${inventoryPath}/sources`;
setBreadcrumbConfig({
'/inventories': i18n._(t`Inventories`), '/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create new inventory`), '/inventories/inventory/add': i18n._(t`Create new inventory`),
'/inventories/smart_inventory/add': i18n._( '/inventories/smart_inventory/add': i18n._(
t`Create new smart inventory` t`Create new smart inventory`
), ),
},
};
}
setBreadCrumbConfig = (inventory, nested, schedule) => { [inventoryPath]: `${inventory.name}`,
const { i18n } = this.props; [`${inventoryPath}/access`]: i18n._(t`Access`),
if (!inventory) { [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
return; [`${inventoryPath}/details`]: i18n._(t`Details`),
} [`${inventoryPath}/edit`]: i18n._(t`Edit details`),
const inventoryKind = [inventoryHostsPath]: i18n._(t`Hosts`),
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
[`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`,
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
[`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(
t`Host Details`
),
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
t`Completed jobs`
),
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`; [inventoryGroupsPath]: i18n._(t`Groups`),
const inventoryHostsPath = `${inventoryPath}/hosts`; [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
const inventoryGroupsPath = `${inventoryPath}/groups`; [`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`,
const inventorySourcesPath = `${inventoryPath}/sources`; [`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
t`Group details`
),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
t`Create new host`
),
const breadcrumbConfig = { [`${inventorySourcesPath}`]: i18n._(t`Sources`),
'/inventories': i18n._(t`Inventories`), [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
'/inventories/inventory/add': i18n._(t`Create new inventory`), [`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), [`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
[`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._(
t`Schedules`
),
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._(
t`Schedule Details`
),
});
},
[i18n]
);
[inventoryPath]: `${inventory.name}`, return (
[`${inventoryPath}/access`]: i18n._(t`Access`), <>
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), <Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
[`${inventoryPath}/details`]: i18n._(t`Details`), <Switch>
[`${inventoryPath}/edit`]: i18n._(t`Edit details`), <Route path="/inventories/inventory/add">
<InventoryAdd />
[inventoryHostsPath]: i18n._(t`Hosts`), </Route>
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), <Route path="/inventories/smart_inventory/add">
[`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, <SmartInventoryAdd />
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), </Route>
[`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(t`Host Details`), <Route path="/inventories/inventory/:id">
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( <Config>
t`Completed jobs` {({ me }) => (
), <Inventory setBreadcrumb={buildBreadcrumbConfig} me={me || {}} />
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
[inventoryGroupsPath]: i18n._(t`Groups`),
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
[`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`,
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
t`Group details`
),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
t`Create new host`
),
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
[`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._(t`Schedules`),
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._(
t`Schedule Details`
),
};
this.setState({ breadcrumbConfig });
};
render() {
const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state;
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path={`${match.path}/inventory/add`}>
<InventoryAdd />
</Route>
<Route path={`${match.path}/smart_inventory/add`}>
<SmartInventoryAdd />
</Route>
<Route path={`${match.path}/inventory/:id`}>
<Config>
{({ me }) => (
<Inventory
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
/>
)}
</Config>
</Route>
<Route
path={`${match.path}/smart_inventory/:id`}
render={({ match: newRouteMatch }) => (
<Config>
{({ me }) => (
<SmartInventory
history={history}
location={location}
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
match={newRouteMatch}
/>
)}
</Config>
)} )}
/> </Config>
<Route path={`${match.path}`}> </Route>
<InventoryList /> <Route path="/inventories/smart_inventory/:id">
</Route> <Config>
</Switch> {({ me }) => (
</> <SmartInventory
); setBreadcrumb={buildBreadcrumbConfig}
} me={me || {}}
/>
)}
</Config>
</Route>
<Route path="/inventories">
<InventoryList />
</Route>
</Switch>
</>
);
} }
export { Inventories as _Inventories }; export { Inventories as _Inventories };
export default withI18n()(withRouter(Inventories)); export default withI18n()(Inventories);

View File

@ -108,6 +108,12 @@ function Inventory({ i18n, setBreadcrumb }) {
showCardHeader = false; showCardHeader = false;
} }
if (inventory?.kind === 'smart') {
return (
<Redirect to={`/inventories/smart_inventory/${inventory.id}/details`} />
);
}
return ( return (
<PageSection> <PageSection>
<Card> <Card>

View File

@ -73,10 +73,12 @@ function InventoryHostDetail({ i18n, host }) {
<HostToggle host={host} css="padding-bottom: 40px" /> <HostToggle host={host} css="padding-bottom: 40px" />
<DetailList gutter="sm"> <DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} /> <Detail label={i18n._(t`Name`)} value={name} />
<Detail {recentPlaybookJobs?.length > 0 && (
label={i18n._(t`Activity`)} <Detail
value={<Sparkline jobs={recentPlaybookJobs} />} label={i18n._(t`Activity`)}
/> value={<Sparkline jobs={recentPlaybookJobs} />}
/>
)}
<Detail label={i18n._(t`Description`)} value={description} /> <Detail label={i18n._(t`Description`)} value={description} />
<UserDateDetail <UserDateDetail
date={created} date={created}

View File

@ -32,6 +32,9 @@ describe('<InventoryHostDetail />', () => {
assertDetail('Description', 'localhost description'); assertDetail('Description', 'localhost description');
assertDetail('Created', '10/28/2019, 9:26:54 PM'); assertDetail('Created', '10/28/2019, 9:26:54 PM');
assertDetail('Last Modified', '10/29/2019, 8:18:41 PM'); assertDetail('Last Modified', '10/29/2019, 8:18:41 PM');
expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(
1
);
}); });
test('should show edit button for users with edit permission', () => { test('should show edit button for users with edit permission', () => {

View File

@ -1,181 +1,183 @@
import React, { Component } from 'react'; import React, { useCallback, useEffect } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Link,
Switch,
Route,
Redirect,
useRouteMatch,
useLocation,
} from 'react-router-dom';
import { CaretLeftIcon } from '@patternfly/react-icons'; import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { Switch, Route, Redirect, withRouter, Link } from 'react-router-dom';
import ContentError from '../../components/ContentError'; import useRequest from '../../util/useRequest';
import JobList from '../../components/JobList';
import RoutedTabs from '../../components/RoutedTabs';
import { ResourceAccessList } from '../../components/ResourceAccessList';
import SmartInventoryDetail from './SmartInventoryDetail';
import SmartInventoryHosts from './SmartInventoryHosts';
import { InventoriesAPI } from '../../api'; import { InventoriesAPI } from '../../api';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import JobList from '../../components/JobList';
import { ResourceAccessList } from '../../components/ResourceAccessList';
import RoutedTabs from '../../components/RoutedTabs';
import SmartInventoryDetail from './SmartInventoryDetail';
import SmartInventoryEdit from './SmartInventoryEdit'; import SmartInventoryEdit from './SmartInventoryEdit';
import SmartInventoryHosts from './SmartInventoryHosts';
class SmartInventory extends Component { function SmartInventory({ i18n, setBreadcrumb }) {
constructor(props) { const location = useLocation();
super(props); const match = useRouteMatch('/inventories/smart_inventory/:id');
this.state = { const {
contentError: null, result: { inventory },
hasContentLoading: true, error: contentError,
isLoading: hasContentLoading,
request: fetchInventory,
} = useRequest(
useCallback(async () => {
const { data } = await InventoriesAPI.readDetail(match.params.id);
return {
inventory: data,
};
}, [match.params.id]),
{
inventory: null, inventory: null,
};
this.loadSmartInventory = this.loadSmartInventory.bind(this);
}
async componentDidMount() {
await this.loadSmartInventory();
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/inventories/smart_inventory/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadSmartInventory();
} }
} );
async loadSmartInventory() { useEffect(() => {
const { setBreadcrumb, match } = this.props; fetchInventory();
const { id } = match.params; }, [fetchInventory, location.pathname]);
this.setState({ contentError: null, hasContentLoading: true }); useEffect(() => {
try { if (inventory) {
const { data } = await InventoriesAPI.readDetail(id); setBreadcrumb(inventory);
setBreadcrumb(data);
this.setState({ inventory: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
} }
} }, [inventory, setBreadcrumb]);
render() { const tabsArray = [
const { i18n, location, match } = this.props; {
const { contentError, hasContentLoading, inventory } = this.state; name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Inventories`)}
</>
),
link: `/inventories`,
id: 99,
},
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Hosts`), link: `${match.url}/hosts`, id: 2 },
{
name: i18n._(t`Completed jobs`),
link: `${match.url}/completed_jobs`,
id: 3,
},
];
const tabsArray = [ if (hasContentLoading) {
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Inventories`)}
</>
),
link: `/inventories`,
id: 99,
},
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Hosts`), link: `${match.url}/hosts`, id: 2 },
{
name: i18n._(t`Completed Jobs`),
link: `${match.url}/completed_jobs`,
id: 3,
},
];
let showCardHeader = true;
if (location.pathname.endsWith('edit')) {
showCardHeader = false;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card>
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`Inventory not found.`)}{' '}
<Link to="/inventories">
{i18n._(`View all Inventories.`)}
</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />} <ContentLoading />
<Switch>
<Redirect
from="/inventories/smart_inventory/:id"
to="/inventories/smart_inventory/:id/details"
exact
/>
{inventory && [
<Route
key="details"
path="/inventories/smart_inventory/:id/details"
>
<SmartInventoryDetail
hasSmartInventoryLoading={hasContentLoading}
inventory={inventory}
/>
</Route>,
<Route key="edit" path="/inventories/smart_inventory/:id/edit">
<SmartInventoryEdit inventory={inventory} />
</Route>,
<Route
key="access"
path="/inventories/smart_inventory/:id/access"
>
<ResourceAccessList
resource={inventory}
apiModel={InventoriesAPI}
/>
</Route>,
<Route key="hosts" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHosts inventory={inventory} />
</Route>,
<Route
key="completed_jobs"
path="/inventories/smart_inventory/:id/completed_jobs"
>
<JobList
defaultParams={{
or__job__inventory: inventory.id,
or__adhoccommand__inventory: inventory.id,
or__inventoryupdate__inventory_source__inventory:
inventory.id,
or__workflowjob__inventory: inventory.id,
}}
/>
</Route>,
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/inventories/smart_inventory/${match.params.id}/details`}
>
{i18n._(`View Inventory Details`)}
</Link>
)}
</ContentError>
)}
</Route>,
]}
</Switch>
</Card> </Card>
</PageSection> </PageSection>
); );
} }
if (contentError) {
return (
<PageSection>
<Card>
<ContentError error={contentError}>
{contentError?.response?.status === 404 && (
<span>
{i18n._(`Smart Inventory not found.`)}{' '}
<Link to="/inventories">{i18n._(`View all Inventories.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
if (inventory?.kind === '') {
return <Redirect to={`/inventories/inventory/${inventory.id}/details`} />;
}
let showCardHeader = true;
if (location.pathname.endsWith('edit')) {
showCardHeader = false;
}
return (
<PageSection>
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
<Switch>
<Redirect
from="/inventories/smart_inventory/:id"
to="/inventories/smart_inventory/:id/details"
exact
/>
{inventory && [
<Route
key="details"
path="/inventories/smart_inventory/:id/details"
>
<SmartInventoryDetail
isLoading={hasContentLoading}
inventory={inventory}
/>
</Route>,
<Route key="edit" path="/inventories/smart_inventory/:id/edit">
<SmartInventoryEdit inventory={inventory} />
</Route>,
<Route key="access" path="/inventories/smart_inventory/:id/access">
<ResourceAccessList
resource={inventory}
apiModel={InventoriesAPI}
/>
</Route>,
<Route key="hosts" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHosts inventory={inventory} />
</Route>,
<Route
key="completed_jobs"
path="/inventories/smart_inventory/:id/completed_jobs"
>
<JobList
defaultParams={{
or__job__inventory: inventory.id,
or__adhoccommand__inventory: inventory.id,
or__inventoryupdate__inventory_source__inventory:
inventory.id,
or__workflowjob__inventory: inventory.id,
}}
/>
</Route>,
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/inventories/smart_inventory/${match.params.id}/details`}
>
{i18n._(`View Inventory Details`)}
</Link>
)}
</ContentError>
)}
</Route>,
]}
</Switch>
</Card>
</PageSection>
);
} }
export { SmartInventory as _SmartInventory }; export { SmartInventory as _SmartInventory };
export default withI18n()(withRouter(SmartInventory)); export default withI18n()(SmartInventory);

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { InventoriesAPI } from '../../api'; import { InventoriesAPI } from '../../api';
import { import {
@ -9,36 +10,51 @@ import mockSmartInventory from './shared/data.smart_inventory.json';
import SmartInventory from './SmartInventory'; import SmartInventory from './SmartInventory';
jest.mock('../../api'); jest.mock('../../api');
jest.mock('react-router-dom', () => ({
InventoriesAPI.readDetail.mockResolvedValue({ ...jest.requireActual('react-router-dom'),
data: mockSmartInventory, useRouteMatch: () => ({
}); url: '/inventories/smart_inventory/1',
params: { id: 1 },
}),
}));
describe('<SmartInventory />', () => { describe('<SmartInventory />', () => {
test('initially renders succesfully', async done => { let wrapper;
const wrapper = mountWithContexts(
<SmartInventory setBreadcrumb={() => {}} match={{ params: { id: 1 } }} /> afterEach(() => {
); wrapper.unmount();
await waitForElement( jest.clearAllMocks();
wrapper,
'SmartInventory',
el => el.state('hasContentLoading') === true
);
await waitForElement(
wrapper,
'SmartInventory',
el => el.state('hasContentLoading') === false
);
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 5);
done();
}); });
test('initially renders succesfully', async () => {
InventoriesAPI.readDetail.mockResolvedValue({
data: mockSmartInventory,
});
await act(async () => {
wrapper = mountWithContexts(<SmartInventory setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'SmartInventory');
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 5);
});
test('should show content error when api throws an error', async () => {
const error = new Error();
error.response = { status: 404 };
InventoriesAPI.readDetail.mockRejectedValueOnce(error);
await act(async () => {
wrapper = mountWithContexts(<SmartInventory setBreadcrumb={() => {}} />);
});
expect(InventoriesAPI.readDetail).toHaveBeenCalledTimes(1);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
});
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 () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/inventories/smart_inventory/1/foobar'], initialEntries: ['/inventories/smart_inventory/1/foobar'],
}); });
const wrapper = mountWithContexts( await act(async () => {
<SmartInventory setBreadcrumb={() => {}} />, wrapper = mountWithContexts(<SmartInventory setBreadcrumb={() => {}} />, {
{
context: { context: {
router: { router: {
history, history,
@ -52,8 +68,8 @@ describe('<SmartInventory />', () => {
}, },
}, },
}, },
} });
); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
}); });

View File

@ -1,10 +1,193 @@
import React, { Component } from 'react'; import React, { useCallback, useEffect } from 'react';
import { CardBody } from '../../../components/Card'; import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { shape } from 'prop-types';
import { Button, Chip, Label } from '@patternfly/react-core';
class SmartInventoryDetail extends Component { import { Inventory } from '../../../types';
render() { import { InventoriesAPI, UnifiedJobsAPI } from '../../../api';
return <CardBody>Coming soon :)</CardBody>; import useRequest, { useDismissableError } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup';
import { VariablesDetail } from '../../../components/CodeMirrorInput';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import DeleteButton from '../../../components/DeleteButton';
import {
DetailList,
Detail,
UserDateDetail,
} from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail';
import Sparkline from '../../../components/Sparkline';
function SmartInventoryDetail({ inventory, i18n }) {
const history = useHistory();
const {
created,
description,
host_filter,
id,
modified,
name,
variables,
summary_fields: {
created_by,
modified_by,
organization,
user_capabilities,
},
} = inventory;
const {
error: contentError,
isLoading: hasContentLoading,
request: fetchData,
result: { recentJobs, instanceGroups },
} = useRequest(
useCallback(async () => {
const params = {
or__job__inventory: id,
or__workflowjob__inventory: id,
order_by: '-finished',
page_size: 10,
};
const [{ data: jobData }, { data: igData }] = await Promise.all([
UnifiedJobsAPI.read(params),
InventoriesAPI.readInstanceGroups(id),
]);
return {
recentJobs: jobData.results,
instanceGroups: igData.results,
};
}, [id]),
{
recentJobs: [],
instanceGroups: [],
}
);
useEffect(() => {
fetchData();
}, [fetchData]);
const { error: deleteError, isLoading, request: handleDelete } = useRequest(
useCallback(async () => {
await InventoriesAPI.destroy(id);
history.push(`/inventories`);
}, [id, history])
);
const { error, dismissError } = useDismissableError(deleteError);
if (hasContentLoading) {
return <ContentLoading />;
} }
if (contentError) {
return <ContentError error={contentError} />;
}
return (
<>
<CardBody>
<DetailList>
<Detail label={i18n._(t`Name`)} value={name} />
{recentJobs.length > 0 && (
<Detail
label={i18n._(t`Activity`)}
value={<Sparkline jobs={recentJobs} />}
/>
)}
<Detail label={i18n._(t`Description`)} value={description} />
<Detail label={i18n._(t`Type`)} value={i18n._(t`Smart inventory`)} />
<Detail
label={i18n._(t`Organization`)}
value={
<Link to={`/organizations/${organization.id}/details`}>
{organization.name}
</Link>
}
/>
<Detail
fullWidth
label={i18n._(t`Smart host filter`)}
value={<Label variant="outline">{host_filter}</Label>}
/>
{instanceGroups.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Instance groups`)}
value={
<ChipGroup numChips={5} totalChips={instanceGroups.length}>
{instanceGroups.map(ig => (
<Chip key={ig.id} isReadOnly>
{ig.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<VariablesDetail
label={i18n._(t`Variables`)}
value={variables}
rows={4}
/>
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={created_by}
/>
<UserDateDetail
label={i18n._(t`Last modified`)}
date={modified}
user={modified_by}
/>
</DetailList>
<CardActionsRow>
{user_capabilities?.edit && (
<Button
component={Link}
aria-label="edit"
to={`/inventories/smart_inventory/${id}/edit`}
>
{i18n._(t`Edit`)}
</Button>
)}
{user_capabilities?.delete && (
<DeleteButton
name={name}
modalTitle={i18n._(t`Delete smart inventory`)}
onConfirm={handleDelete}
isDisabled={isLoading}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow>
</CardBody>
{error && (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
>
{i18n._(t`Failed to delete smart inventory.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
} }
export default SmartInventoryDetail; SmartInventoryDetail.propTypes = {
inventory: Inventory.isRequired,
i18n: shape({}).isRequired,
};
export default withI18n()(SmartInventoryDetail);

View File

@ -0,0 +1,155 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import SmartInventoryDetail from './SmartInventoryDetail';
import { InventoriesAPI, UnifiedJobsAPI } from '../../../api';
import mockSmartInventory from '../shared/data.smart_inventory.json';
jest.mock('../../../api/models/UnifiedJobs');
jest.mock('../../../api/models/Inventories');
UnifiedJobsAPI.read.mockResolvedValue({
data: {
results: [
{
id: 1,
name: 'job 1',
type: 'job',
status: 'successful',
},
],
},
});
InventoriesAPI.readInstanceGroups.mockResolvedValue({
data: {
results: [{ id: 1, name: 'mock instance group' }],
},
});
describe('<SmartInventoryDetail />', () => {
let wrapper;
describe('User has edit permissions', () => {
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryDetail inventory={mockSmartInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should render Details', async () => {
function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
}
assertDetail('Name', 'Smart Inv');
assertDetail('Description', 'smart inv description');
assertDetail('Type', 'Smart inventory');
assertDetail('Organization', 'Default');
assertDetail('Smart host filter', 'search=local');
assertDetail('Instance groups', 'mock instance group');
expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(
1
);
const vars = wrapper.find('VariablesDetail');
expect(vars).toHaveLength(1);
expect(vars.prop('value')).toEqual(mockSmartInventory.variables);
const dates = wrapper.find('UserDateDetail');
expect(dates).toHaveLength(2);
expect(dates.at(0).prop('date')).toEqual(mockSmartInventory.created);
expect(dates.at(1).prop('date')).toEqual(mockSmartInventory.modified);
});
test('should show edit button for users with edit permission', () => {
const editButton = wrapper.find('Button[aria-label="edit"]');
expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe(
`/inventories/smart_inventory/${mockSmartInventory.id}/edit`
);
});
test('expected api calls are made on initial render', () => {
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
expect(UnifiedJobsAPI.read).toHaveBeenCalledTimes(1);
});
test('expected api call is made for delete', async () => {
expect(InventoriesAPI.destroy).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('DeleteButton').invoke('onConfirm')();
});
expect(InventoriesAPI.destroy).toHaveBeenCalledTimes(1);
});
test('Error dialog shown for failed deletion', async () => {
InventoriesAPI.destroy.mockImplementationOnce(() =>
Promise.reject(new Error())
);
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
);
});
});
describe('User has read-only permissions', () => {
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should hide edit button for users without edit permission', async () => {
const readOnlySmartInv = { ...mockSmartInventory };
readOnlySmartInv.summary_fields.user_capabilities.edit = false;
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryDetail inventory={readOnlySmartInv} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(0);
});
test('should show content error when jobs request fails', async () => {
UnifiedJobsAPI.read.mockImplementationOnce(() =>
Promise.reject(new Error())
);
expect(UnifiedJobsAPI.read).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryDetail inventory={mockSmartInventory} />
);
});
expect(UnifiedJobsAPI.read).toHaveBeenCalledTimes(1);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual(
'Something went wrong...'
);
});
});
});

View File

@ -77,7 +77,7 @@
"created": "2019-10-04T15:29:11.542911Z", "created": "2019-10-04T15:29:11.542911Z",
"modified": "2019-10-04T15:29:11.542924Z", "modified": "2019-10-04T15:29:11.542924Z",
"name": "Smart Inv", "name": "Smart Inv",
"description": "", "description": "smart inv description",
"organization": 1, "organization": 1,
"kind": "smart", "kind": "smart",
"host_filter": "search=local", "host_filter": "search=local",