add inventory host groups list and host groups lists

This commit is contained in:
John Mitchell 2020-04-02 15:02:41 -04:00
parent a682565758
commit ab36129395
15 changed files with 924 additions and 7 deletions

View File

@ -4,11 +4,23 @@ class Hosts extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/hosts/';
this.readFacts = this.readFacts.bind(this);
this.readGroups = this.readGroups.bind(this);
this.readGroupsOptions = this.readGroupsOptions.bind(this);
}
readFacts(id) {
return this.http.get(`${this.baseUrl}${id}/ansible_facts/`);
}
readGroups(id, params) {
return this.http.get(`${this.baseUrl}${id}/groups/`, { params });
}
readGroupsOptions(id) {
return this.http.options(`${this.baseUrl}${id}/groups/`);
}
}
export default Hosts;

View File

@ -0,0 +1,69 @@
import React from 'react';
import { bool, func, number, oneOfType, string } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Group } from '@types';
import {
Button,
DataListAction,
DataListCheck,
DataListItem,
DataListItemCells,
DataListItemRow,
Tooltip,
} from '@patternfly/react-core';
import DataListCell from '@components/DataListCell';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
function HostGroupItem({ i18n, group, inventoryId, isSelected, onSelect }) {
const labelId = `check-action-${group.id}`;
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`;
const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`;
return (
<DataListItem key={group.id} aria-labelledby={labelId} id={`${group.id}`}>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
id={`select-group-${group.id}`}
checked={isSelected}
onChange={onSelect}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${detailUrl}`} id={labelId}>
<b>{group.name}</b>
</Link>
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{group.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Group`)} position="top">
<Button variant="plain" component={Link} to={editUrl}>
<PencilAltIcon />
</Button>
</Tooltip>
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
HostGroupItem.propTypes = {
group: Group.isRequired,
inventoryId: oneOfType([number, string]).isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(HostGroupItem);

View File

@ -0,0 +1,52 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import HostGroupItem from './HostGroupItem';
describe('<HostGroupItem />', () => {
let wrapper;
const mockGroup = {
id: 2,
type: 'group',
name: 'foo',
inventory: 1,
summary_fields: {
user_capabilities: {
edit: true,
},
},
};
beforeEach(() => {
wrapper = mountWithContexts(
<HostGroupItem
group={mockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
});
test('initially renders successfully', () => {
expect(wrapper.find('HostGroupItem').length).toBe(1);
});
test('edit button should be shown to users with edit capabilities', () => {
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button should be hidden from users without edit capabilities', () => {
const copyMockGroup = Object.assign({}, mockGroup);
copyMockGroup.summary_fields.user_capabilities.edit = false;
wrapper = mountWithContexts(
<HostGroupItem
group={copyMockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});

View File

@ -1,10 +1,23 @@
import React, { Component } from 'react';
import { CardBody } from '@components/Card';
import React from 'react';
import { withI18n } from '@lingui/react';
class HostGroups extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
import { Switch, Route, withRouter } from 'react-router-dom';
import HostGroupsList from './HostGroupsList';
function HostGroups({ location, match }) {
return (
<Switch>
<Route
key="list"
path="/hosts/:id/groups"
render={() => {
return <HostGroupsList location={location} match={match} />;
}}
/>
</Switch>
);
}
export default HostGroups;
export { HostGroups as _HostGroups };
export default withI18n()(withRouter(HostGroups));

View File

@ -0,0 +1,31 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import HostGroups from './HostGroups';
jest.mock('@api');
describe('<HostGroups />', () => {
test('initially renders successfully', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/hosts/1/groups'],
});
const host = { id: 1, name: 'Foo' };
await act(async () => {
wrapper = mountWithContexts(
<HostGroups setBreadcrumb={() => {}} host={host} />,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('HostGroupsList').length).toBe(1);
});
});

View File

@ -0,0 +1,119 @@
import React, { useState, useEffect, useCallback } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import useRequest from '@util/useRequest';
import { HostsAPI } from '@api';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList from '@components/PaginatedDataList';
import HostGroupItem from './HostGroupItem';
const QS_CONFIG = getQSConfig('group', {
page: 1,
page_size: 20,
order_by: 'name',
});
function HostGroupsList({ i18n, location, match }) {
const [selected, setSelected] = useState([]);
const hostId = match.params.id;
const {
result: { groups, itemCount },
error: contentError,
isLoading,
request: fetchGroups,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const {
data: { count, results },
} = await HostsAPI.readGroups(hostId, params);
return {
itemCount: count,
groups: results,
};
}, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps
{
groups: [],
itemCount: 0,
}
);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...groups] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const isAllSelected =
selected.length > 0 && selected.length === groups.length;
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={groups}
itemCount={itemCount}
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',
},
]}
renderItem={item => (
<HostGroupItem
key={item.id}
group={item}
hostId={hostId}
inventoryId={item.summary_fields.inventory.id}
isSelected={selected.some(row => row.id === item.id)}
onSelect={() => handleSelect(item)}
/>
)}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
/>
)}
/>
</>
);
}
export default withI18n()(withRouter(HostGroupsList));

View File

@ -0,0 +1,158 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { HostsAPI } from '@api';
import HostGroupsList from './HostGroupsList';
jest.mock('@api');
const mockGroups = [
{
id: 1,
type: 'group',
name: 'foo',
inventory: 1,
url: '/api/v2/groups/1',
summary_fields: {
inventory: {
id: 1,
},
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 2,
type: 'group',
name: 'bar',
inventory: 1,
url: '/api/v2/groups/2',
summary_fields: {
inventory: {
id: 1,
},
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 3,
type: 'group',
name: 'baz',
inventory: 1,
url: '/api/v2/groups/3',
summary_fields: {
inventory: {
id: 1,
},
user_capabilities: {
delete: false,
edit: false,
},
},
},
];
describe('<HostGroupsList />', () => {
let wrapper;
beforeEach(async () => {
HostsAPI.readGroups.mockResolvedValue({
data: {
count: mockGroups.length,
results: mockGroups,
},
});
HostsAPI.readGroupsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
const history = createMemoryHistory({
initialEntries: ['/hosts/3/groups'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/hosts/:id/groups" component={() => <HostGroupsList />} />,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders successfully', () => {
expect(wrapper.find('HostGroupsList').length).toBe(1);
});
test('should fetch groups from api and render them in the list', async () => {
expect(HostsAPI.readGroups).toHaveBeenCalled();
expect(wrapper.find('HostGroupItem').length).toBe(3);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('DataListCheck[id="select-group-1"]').props().checked
).toBe(false);
await act(async () => {
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(
true
);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-group-1"]').props().checked
).toBe(true);
await act(async () => {
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(
false
);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-group-1"]').props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
test('should show content error when api throws error on initial render', async () => {
HostsAPI.readGroups.mockImplementation(() => Promise.reject(new Error()));
await act(async () => {
wrapper = mountWithContexts(<HostGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});

View File

@ -23,6 +23,7 @@ import JobList from '@components/JobList';
import InventoryHostDetail from '../InventoryHostDetail';
import InventoryHostEdit from '../InventoryHostEdit';
import InventoryHostFacts from '../InventoryHostFacts';
import InventoryHostGroups from '../InventoryHostGroups';
function InventoryHost({ i18n, setBreadcrumb, inventory }) {
const location = useLocation();
@ -147,6 +148,12 @@ function InventoryHost({ i18n, setBreadcrumb, inventory }) {
>
<InventoryHostFacts host={host} />
</Route>
<Route
key="groups"
path="/inventories/inventory/:id/hosts/:hostId/groups"
>
<InventoryHostGroups />
</Route>
<Route
key="completed-jobs"
path="/inventories/inventory/:id/hosts/:hostId/completed_jobs"

View File

@ -0,0 +1,75 @@
import React from 'react';
import { bool, func, number, oneOfType, string } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Group } from '@types';
import {
Button,
DataListAction,
DataListCheck,
DataListItem,
DataListItemCells,
DataListItemRow,
Tooltip,
} from '@patternfly/react-core';
import DataListCell from '@components/DataListCell';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
function InventoryHostGroupItem({
i18n,
group,
inventoryId,
isSelected,
onSelect,
}) {
const labelId = `check-action-${group.id}`;
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`;
const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`;
return (
<DataListItem key={group.id} aria-labelledby={labelId} id={`${group.id}`}>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
id={`select-group-${group.id}`}
checked={isSelected}
onChange={onSelect}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link to={`${detailUrl}`} id={labelId}>
<b>{group.name}</b>
</Link>
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{group.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Group`)} position="top">
<Button variant="plain" component={Link} to={editUrl}>
<PencilAltIcon />
</Button>
</Tooltip>
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
InventoryHostGroupItem.propTypes = {
group: Group.isRequired,
inventoryId: oneOfType([number, string]).isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(InventoryHostGroupItem);

View File

@ -0,0 +1,52 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryHostGroupItem from './InventoryHostGroupItem';
describe('<InventoryHostGroupItem />', () => {
let wrapper;
const mockGroup = {
id: 2,
type: 'group',
name: 'foo',
inventory: 1,
summary_fields: {
user_capabilities: {
edit: true,
},
},
};
beforeEach(() => {
wrapper = mountWithContexts(
<InventoryHostGroupItem
group={mockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryHostGroupItem').length).toBe(1);
});
test('edit button should be shown to users with edit capabilities', () => {
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
});
test('edit button should be hidden from users without edit capabilities', () => {
const copyMockGroup = Object.assign({}, mockGroup);
copyMockGroup.summary_fields.user_capabilities.edit = false;
wrapper = mountWithContexts(
<InventoryHostGroupItem
group={copyMockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});

View File

@ -0,0 +1,23 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { Switch, Route, withRouter } from 'react-router-dom';
import InventoryHostGroupsList from './InventoryHostGroupsList';
function InventoryHostGroups({ location, match }) {
return (
<Switch>
<Route
key="list"
path="/inventories/inventory/:id/hosts/:hostId/groups"
render={() => {
return <InventoryHostGroupsList location={location} match={match} />;
}}
/>
</Switch>
);
}
export { InventoryHostGroups as _InventoryHostGroups };
export default withI18n()(withRouter(InventoryHostGroups));

View File

@ -0,0 +1,26 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import InventoryHostGroups from './InventoryHostGroups';
jest.mock('@api');
describe('<InventoryHostGroups />', () => {
test('initially renders successfully', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/1/groups'],
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHostGroups />, {
context: {
router: { history, route: { location: history.location } },
},
});
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('InventoryHostGroupsList').length).toBe(1);
});
});

View File

@ -0,0 +1,118 @@
import React, { useState, useEffect, useCallback } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import useRequest from '@util/useRequest';
import { HostsAPI } from '@api';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList from '@components/PaginatedDataList';
import InventoryHostGroupItem from './InventoryHostGroupItem';
const QS_CONFIG = getQSConfig('group', {
page: 1,
page_size: 20,
order_by: 'name',
});
function InventoryHostGroupsList({ i18n, location, match }) {
const [selected, setSelected] = useState([]);
const { hostId } = match.params;
const {
result: { groups, itemCount },
error: contentError,
isLoading,
request: fetchGroups,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const {
data: { count, results },
} = await HostsAPI.readGroups(hostId, params);
return {
itemCount: count,
groups: results,
};
}, [hostId, location]), // eslint-disable-line react-hooks/exhaustive-deps
{
groups: [],
itemCount: 0,
}
);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...groups] : []);
};
const handleSelect = row => {
if (selected.some(s => s.id === row.id)) {
setSelected(selected.filter(s => s.id !== row.id));
} else {
setSelected(selected.concat(row));
}
};
const isAllSelected =
selected.length > 0 && selected.length === groups.length;
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={groups}
itemCount={itemCount}
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',
},
]}
renderItem={item => (
<InventoryHostGroupItem
key={item.id}
group={item}
inventoryId={item.summary_fields.inventory.id}
isSelected={selected.some(row => row.id === item.id)}
onSelect={() => handleSelect(item)}
/>
)}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
/>
)}
/>
</>
);
}
export default withI18n()(withRouter(InventoryHostGroupsList));

View File

@ -0,0 +1,161 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { HostsAPI } from '@api';
import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('@api');
const mockGroups = [
{
id: 1,
type: 'group',
name: 'foo',
inventory: 1,
url: '/api/v2/groups/1',
summary_fields: {
inventory: {
id: 1,
},
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 2,
type: 'group',
name: 'bar',
inventory: 1,
url: '/api/v2/groups/2',
summary_fields: {
inventory: {
id: 1,
},
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 3,
type: 'group',
name: 'baz',
inventory: 1,
url: '/api/v2/groups/3',
summary_fields: {
inventory: {
id: 1,
},
user_capabilities: {
delete: false,
edit: false,
},
},
},
];
describe('<InventoryHostGroupsList />', () => {
let wrapper;
beforeEach(async () => {
HostsAPI.readGroups.mockResolvedValue({
data: {
count: mockGroups.length,
results: mockGroups,
},
});
HostsAPI.readGroupsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/3/groups'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route
path="/inventories/inventory/:id/hosts/:hostId/groups"
component={() => <InventoryHostGroupsList />}
/>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryHostGroupsList').length).toBe(1);
});
test('should fetch groups from api and render them in the list', async () => {
expect(HostsAPI.readGroups).toHaveBeenCalled();
expect(wrapper.find('InventoryHostGroupItem').length).toBe(3);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('DataListCheck[id="select-group-1"]').props().checked
).toBe(false);
await act(async () => {
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(
true
);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-group-1"]').props().checked
).toBe(true);
await act(async () => {
wrapper.find('DataListCheck[id="select-group-1"]').invoke('onChange')(
false
);
});
wrapper.update();
expect(
wrapper.find('DataListCheck[id="select-group-1"]').props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
test('should show content error when api throws error on initial render', async () => {
HostsAPI.readGroups.mockImplementation(() => Promise.reject(new Error()));
await act(async () => {
wrapper = mountWithContexts(<InventoryHostGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
});

View File

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