Add smart inventory host list view

This commit is contained in:
Marliana Lara 2020-08-03 17:46:28 -04:00
parent 025a979cb2
commit 8a4d45ddb6
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
12 changed files with 466 additions and 26 deletions

View File

@ -8,7 +8,7 @@ import ErrorDetail from '../ErrorDetail';
import useRequest from '../../util/useRequest';
import { HostsAPI } from '../../api';
function HostToggle({ host, onToggle, className, i18n }) {
function HostToggle({ host, isDisabled = false, onToggle, className, i18n }) {
const [isEnabled, setIsEnabled] = useState(host.enabled);
const [showError, setShowError] = useState(false);
@ -54,7 +54,11 @@ function HostToggle({ host, onToggle, className, i18n }) {
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={isEnabled}
isDisabled={isLoading || !host.summary_fields.user_capabilities.edit}
isDisabled={
isLoading ||
isDisabled ||
!host.summary_fields.user_capabilities.edit
}
onChange={toggleHost}
aria-label={i18n._(t`Toggle host`)}
/>

View File

@ -19,7 +19,7 @@ const mockHost = {
},
user_capabilities: {
delete: true,
update: true,
edit: true,
},
recent_jobs: [],
},
@ -68,6 +68,18 @@ describe('<HostToggle>', () => {
expect(onToggle).toHaveBeenCalledWith(true);
});
test('should be enabled', async () => {
const wrapper = mountWithContexts(<HostToggle host={mockHost} />);
expect(wrapper.find('Switch').prop('isDisabled')).toEqual(false);
});
test('should be disabled', async () => {
const wrapper = mountWithContexts(
<HostToggle isDisabled host={mockHost} />
);
expect(wrapper.find('Switch').prop('isDisabled')).toEqual(true);
});
test('should show error modal', async () => {
HostsAPI.update.mockImplementation(() => {
throw new Error('nope');

View File

@ -54,11 +54,7 @@ function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
<Fragment>
<b css="margin-right: 24px">{i18n._(t`Inventory`)}</b>
<Link
to={`/inventories/${
host.summary_fields.inventory.kind === 'smart'
? 'smart_inventory'
: 'inventory'
}/${host.summary_fields.inventory.id}/details`}
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
>
{host.summary_fields.inventory.name}
</Link>

View File

@ -105,14 +105,7 @@ function Inventories({ i18n }) {
</Config>
</Route>
<Route path="/inventories/smart_inventory/:id">
<Config>
{({ me }) => (
<SmartInventory
setBreadcrumb={buildBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
<SmartInventory setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/inventories">
<InventoryList />

View File

@ -77,11 +77,18 @@ describe('<InventoryHostDetail />', () => {
describe('User has read-only permissions', () => {
beforeAll(() => {
const readOnlyHost = { ...mockHost };
const readOnlyHost = {
...mockHost,
summary_fields: {
...mockHost.summary_fields,
user_capabilities: {
...mockHost.summary_fields.user_capabilities,
},
},
};
readOnlyHost.summary_fields.user_capabilities.edit = false;
readOnlyHost.summary_fields.recent_jobs = [];
wrapper = mountWithContexts(<InventoryHostDetail host={mockHost} />);
wrapper = mountWithContexts(<InventoryHostDetail host={readOnlyHost} />);
});
afterAll(() => {

View File

@ -47,7 +47,7 @@ function SmartInventory({ i18n, setBreadcrumb }) {
useEffect(() => {
fetchInventory();
}, [fetchInventory, location.pathname]);
}, [fetchInventory]);
useEffect(() => {
if (inventory) {

View File

@ -0,0 +1,120 @@
import React, { useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList from '../../../components/PaginatedDataList';
import SmartInventoryHostListItem from './SmartInventoryHostListItem';
import useRequest from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI } from '../../../api';
import { Inventory } from '../../../types';
const QS_CONFIG = getQSConfig('host', {
page: 1,
page_size: 20,
order_by: 'name',
});
function SmartInventoryHostList({ i18n, inventory }) {
const location = useLocation();
const {
result: { hosts, count },
error: contentError,
isLoading,
request: fetchHosts,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const { data } = await InventoriesAPI.readHosts(inventory.id, params);
return {
hosts: data.results,
count: data.count,
};
}, [location.search, inventory.id]),
{
hosts: [],
count: 0,
}
);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
hosts
);
useEffect(() => {
fetchHosts();
}, [fetchHosts]);
return (
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={hosts}
itemCount={count}
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created by (username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified by (username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected => setSelected(isSelected ? [...hosts] : [])}
qsConfig={QS_CONFIG}
additionalControls={
inventory?.summary_fields?.user_capabilities?.adhoc
? [
<Button
aria-label={i18n._(t`Run commands`)}
isDisabled={selected.length === 0}
>
{i18n._(t`Run commands`)}
</Button>,
]
: []
}
/>
)}
renderItem={host => (
<SmartInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
/>
)}
/>
);
}
SmartInventoryHostList.propTypes = {
inventory: Inventory.isRequired,
};
export default withI18n()(SmartInventoryHostList);

View File

@ -0,0 +1,136 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { InventoriesAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import SmartInventoryHostList from './SmartInventoryHostList';
import mockInventory from '../shared/data.inventory.json';
import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api');
describe('<SmartInventoryHostList />', () => {
describe('User has adhoc permissions', () => {
let wrapper;
const clonedInventory = {
...mockInventory,
summary_fields: {
...mockInventory.summary_fields,
user_capabilities: {
...mockInventory.summary_fields.user_capabilities,
},
},
};
beforeAll(async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: mockHosts,
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => {
expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
});
test('should fetch hosts from api and render them in the list', () => {
expect(InventoriesAPI.readHosts).toHaveBeenCalled();
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3);
});
test('should disable run commands button when no hosts are selected', () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
);
expect(runCommandsButton.length).toBe(1);
expect(runCommandsButton.prop('disabled')).toEqual(true);
});
test('should enable run commands button when at least one host is selected', () => {
act(() => {
wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')(
true
);
});
wrapper.update();
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
);
expect(runCommandsButton.prop('disabled')).toEqual(false);
});
test('should select and deselect all items', async () => {
act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(true);
});
act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(false);
});
});
test('should show content error when api throws an error', async () => {
InventoriesAPI.readHosts.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={mockInventory} />
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});
describe('User does not have adhoc permissions', () => {
let wrapper;
const clonedInventory = {
...mockInventory,
summary_fields: {
user_capabilities: {
adhoc: false,
},
},
};
test('should hide run commands button', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
);
expect(runCommandsButton.length).toBe(0);
jest.clearAllMocks();
wrapper.unmount();
});
});
});

View File

@ -0,0 +1,84 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
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';
function SmartInventoryHostListItem({
i18n,
detailUrl,
host,
isSelected,
onSelect,
}) {
const recentPlaybookJobs = host.summary_fields.recent_jobs.map(job => ({
...job,
type: 'job',
}));
const labelId = `check-action-${host.id}`;
return (
<DataListItem key={host.id} aria-labelledby={labelId} id={`${host.id}`}>
<DataListItemRow>
<DataListCheck
id={`select-host-${host.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${detailUrl}`}>
<b>{host.name}</b>
</Link>
</DataListCell>,
<DataListCell key="recentJobs">
<Sparkline jobs={recentPlaybookJobs} />
</DataListCell>,
<DataListCell key="inventory">
<>
<b css="margin-right: 24px">{i18n._(t`Inventory`)}</b>
<Link
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
>
{host.summary_fields.inventory.name}
</Link>
</>
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
<HostToggle isDisabled host={host} />
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
SmartInventoryHostListItem.propTypes = {
detailUrl: string.isRequired,
host: Host.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(SmartInventoryHostListItem);

View File

@ -0,0 +1,52 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import SmartInventoryHostListItem from './SmartInventoryHostListItem';
const mockHost = {
id: 2,
name: 'Host Two',
url: '/api/v2/hosts/2',
inventory: 1,
summary_fields: {
inventory: {
id: 1,
name: 'Inv 1',
},
user_capabilities: {
edit: true,
},
recent_jobs: [],
},
};
describe('<SmartInventoryHostListItem />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<SmartInventoryHostListItem
detailUrl="/inventories/smart_inventory/1/hosts/2"
host={mockHost}
isSelected={false}
onSelect={() => {}}
/>
);
});
afterEach(() => {
wrapper.unmount();
});
test('should render expected row cells', () => {
const cells = wrapper.find('DataListCell');
expect(cells).toHaveLength(3);
expect(cells.at(0).text()).toEqual('Host Two');
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,10 +1,18 @@
import React, { Component } from 'react';
import { CardBody } from '../../../components/Card';
import React from 'react';
import { Route } from 'react-router-dom';
import SmartInventoryHostList from './SmartInventoryHostList';
import { Inventory } from '../../../types';
class SmartInventoryHosts extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
function SmartInventoryHosts({ inventory }) {
return (
<Route key="host-list" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHostList inventory={inventory} />
</Route>
);
}
SmartInventoryHosts.propTypes = {
inventory: Inventory.isRequired,
};
export default SmartInventoryHosts;

View File

@ -0,0 +1,28 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import SmartInventoryHosts from './SmartInventoryHosts';
jest.mock('../../../api');
describe('<SmartInventoryHosts />', () => {
test('should render smart inventory host list', () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/smart_inventory/1/hosts'],
});
const match = {
path: '/inventories/smart_inventory/:id/hosts',
url: '/inventories/smart_inventory/1/hosts',
isExact: true,
};
const wrapper = mountWithContexts(
<SmartInventoryHosts inventory={{ id: 1 }} />,
{
context: { router: { history, route: { match } } },
}
);
expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
jest.clearAllMocks();
wrapper.unmount();
});
});