mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 11:00:03 -03:30
Add smart inventory host list view
This commit is contained in:
parent
025a979cb2
commit
8a4d45ddb6
@ -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`)}
|
||||
/>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -47,7 +47,7 @@ function SmartInventory({ i18n, setBreadcrumb }) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchInventory();
|
||||
}, [fetchInventory, location.pathname]);
|
||||
}, [fetchInventory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inventory) {
|
||||
|
||||
@ -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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user