Merge pull request #7919 from marshmalien/7806-smart-inv-detail

Add smart inventory host detail view

Reviewed-by: Daniel Sami
             https://github.com/dsesami
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-08-25 14:47:04 +00:00 committed by GitHub
commit 5507f264e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 341 additions and 35 deletions

View File

@ -111,7 +111,7 @@ function SmartInventory({ i18n, setBreadcrumb }) {
let showCardHeader = true;
if (location.pathname.endsWith('edit')) {
if (['edit', 'hosts/'].some(name => location.pathname.includes(name))) {
showCardHeader = false;
}
@ -145,7 +145,10 @@ function SmartInventory({ i18n, setBreadcrumb }) {
/>
</Route>,
<Route key="hosts" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHosts inventory={inventory} />
<SmartInventoryHosts
inventory={inventory}
setBreadcrumb={setBreadcrumb}
/>
</Route>,
<Route
key="completed_jobs"

View File

@ -0,0 +1,90 @@
import React, { useEffect, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom';
import { CaretLeftIcon } from '@patternfly/react-icons';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import RoutedTabs from '../../../components/RoutedTabs';
import SmartInventoryHostDetail from '../SmartInventoryHostDetail';
import useRequest from '../../../util/useRequest';
import { InventoriesAPI } from '../../../api';
function SmartInventoryHost({ i18n, inventory, setBreadcrumb }) {
const { params, path, url } = useRouteMatch(
'/inventories/smart_inventory/:id/hosts/:hostId'
);
const { result: host, error, isLoading, request: fetchHost } = useRequest(
useCallback(async () => {
const response = await InventoriesAPI.readHostDetail(
inventory.id,
params.hostId
);
return response;
}, [inventory.id, params.hostId]),
null
);
useEffect(() => {
fetchHost();
}, [fetchHost]);
useEffect(() => {
if (inventory && host) {
setBreadcrumb(inventory, host);
}
}, [inventory, host, setBreadcrumb]);
if (error) {
return <ContentError error={error} />;
}
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to Hosts`)}
</>
),
link: `/inventories/smart_inventory/${inventory.id}/hosts`,
id: 0,
},
{
name: i18n._(t`Details`),
link: `${url}/details`,
id: 1,
},
];
return (
<>
<RoutedTabs tabsArray={tabsArray} />
{isLoading && <ContentLoading />}
{!isLoading && host && (
<Switch>
<Redirect
from="/inventories/smart_inventory/:id/hosts/:hostId"
to={`${path}/details`}
exact
/>
<Route key="details" path={`${path}/details`}>
<SmartInventoryHostDetail host={host} />
</Route>
<Route key="not-found" path="*">
<ContentError isNotFound>
<Link to={`${url}/details`}>
{i18n._(t`View smart inventory host details`)}
</Link>
</ContentError>
</Route>
</Switch>
)}
</>
);
}
export default withI18n()(SmartInventoryHost);

View File

@ -0,0 +1,91 @@
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 mockHost from '../shared/data.host.json';
import SmartInventoryHost from './SmartInventoryHost';
jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
params: { id: 1234, hostId: 2 },
path: '/inventories/smart_inventory/:id/hosts/:hostId',
url: '/inventories/smart_inventory/1234/hosts/2',
}),
}));
const mockSmartInventory = {
id: 1234,
name: 'Mock Smart Inventory',
};
describe('<SmartInventoryHost />', () => {
let wrapper;
let history;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should render expected tabs', async () => {
InventoriesAPI.readHostDetail.mockResolvedValue({
data: { ...mockHost },
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHost
inventory={mockSmartInventory}
setBreadcrumb={() => {}}
/>
);
});
const expectedTabs = ['Back to Hosts', 'Details'];
expect(wrapper.find('RoutedTabs li').length).toBe(2);
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.readHostDetail.mockRejectedValueOnce(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHost
inventory={mockSmartInventory}
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/smart_inventory/1/hosts/1/foobar'],
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHost
inventory={mockSmartInventory}
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 './SmartInventoryHost';

View File

@ -0,0 +1,75 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Host } from '../../../types';
import { CardBody } from '../../../components/Card';
import {
Detail,
DetailList,
UserDateDetail,
} from '../../../components/DetailList';
import Sparkline from '../../../components/Sparkline';
import { VariablesDetail } from '../../../components/CodeMirrorInput';
function SmartInventoryHostDetail({ host, i18n }) {
const {
created,
description,
enabled,
modified,
name,
variables,
summary_fields: { inventory, recent_jobs, created_by, modified_by },
} = host;
const recentPlaybookJobs = recent_jobs?.map(job => ({ ...job, type: 'job' }));
return (
<CardBody>
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} />
{recentPlaybookJobs?.length > 0 && (
<Detail
label={i18n._(t`Activity`)}
value={<Sparkline jobs={recentPlaybookJobs} />}
/>
)}
<Detail label={i18n._(t`Description`)} value={description} />
<Detail
label={i18n._(t`Inventory`)}
value={
<Link to={`/inventories/inventory/${inventory?.id}/details`}>
{inventory?.name}
</Link>
}
/>
<Detail
label={i18n._(t`Enabled`)}
value={enabled ? i18n._(t`On`) : i18n._(t`Off`)}
/>
<UserDateDetail
date={created}
label={i18n._(t`Created`)}
user={created_by}
/>
<UserDateDetail
date={modified}
label={i18n._(t`Last modified`)}
user={modified_by}
/>
<VariablesDetail
label={i18n._(t`Variables`)}
rows={4}
value={variables}
/>
</DetailList>
</CardBody>
);
}
SmartInventoryHostDetail.propTypes = {
host: Host.isRequired,
};
export default withI18n()(SmartInventoryHostDetail);

View File

@ -0,0 +1,34 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import SmartInventoryHostDetail from './SmartInventoryHostDetail';
import mockHost from '../shared/data.host.json';
jest.mock('../../../api');
describe('<SmartInventoryHostDetail />', () => {
let wrapper;
beforeAll(() => {
wrapper = mountWithContexts(<SmartInventoryHostDetail host={mockHost} />);
});
afterAll(() => {
wrapper.unmount();
});
test('should render Details', () => {
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', 'localhost');
assertDetail('Description', 'localhost description');
assertDetail('Inventory', 'Mikes Inventory');
assertDetail('Enabled', 'On');
assertDetail('Created', '10/28/2019, 9:26:54 PM');
assertDetail('Last modified', '10/29/2019, 8:18:41 PM');
expect(wrapper.find('Detail[label="Activity"] Sparkline')).toHaveLength(1);
expect(wrapper.find('VariablesDetail')).toHaveLength(1);
});
});

View File

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

View File

@ -2,18 +2,16 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import 'styled-components/macro';
import {
DataListAction,
DataListCheck,
DataListItem,
DataListItemCells,
DataListItemRow,
} from '@patternfly/react-core';
import DataListCell from '../../../components/DataListCell';
import HostToggle from '../../../components/HostToggle';
import Sparkline from '../../../components/Sparkline';
import { Host } from '../../../types';
@ -62,25 +60,6 @@ function SmartInventoryHostListItem({
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
<HostToggle
isDisabled
host={host}
tooltip={
<Trans>
<b>Smart inventory hosts are read-only.</b>
<br />
Toggle indicates if a host is available and should be included
in running jobs. For hosts that are part of an external
inventory, this may be reset by the inventory sync process.
</Trans>
}
/>
</DataListAction>
</DataListItemRow>
</DataListItem>
);

View File

@ -44,9 +44,4 @@ describe('<SmartInventoryHostListItem />', () => {
expect(cells.at(1).find('Sparkline').length).toEqual(1);
expect(cells.at(2).text()).toContain('Inv 1');
});
test('should display disabled host toggle', () => {
expect(wrapper.find('HostToggle').length).toBe(1);
expect(wrapper.find('HostToggle Switch').prop('isDisabled')).toEqual(true);
});
});

View File

@ -1,13 +1,22 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { Route, Switch } from 'react-router-dom';
import SmartInventoryHostList from './SmartInventoryHostList';
import SmartInventoryHost from '../SmartInventoryHost';
import { Inventory } from '../../../types';
function SmartInventoryHosts({ inventory }) {
function SmartInventoryHosts({ inventory, setBreadcrumb }) {
return (
<Route key="host-list" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHostList inventory={inventory} />
</Route>
<Switch>
<Route key="host" path="/inventories/smart_inventory/:id/hosts/:hostId">
<SmartInventoryHost
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
</Route>
<Route key="host-list" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHostList inventory={inventory} />
</Route>
</Switch>
);
}

View File

@ -1,6 +1,10 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import SmartInventoryHosts from './SmartInventoryHosts';
jest.mock('../../../api');
@ -25,4 +29,28 @@ describe('<SmartInventoryHosts />', () => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render smart inventory host details', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/inventories/smart_inventory/1/hosts/2'],
});
const match = {
path: '/inventories/smart_inventory/:id/hosts/:hostId',
url: '/inventories/smart_inventory/1/hosts/2',
isExact: true,
};
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHosts inventory={{ id: 1 }} setBreadcrumb={() => {}} />,
{
context: { router: { history, route: { match } } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('SmartInventoryHost').length).toBe(1);
jest.clearAllMocks();
wrapper.unmount();
});
});