Add Inventory Groups list

This commit is contained in:
Marliana Lara 2019-11-26 15:05:26 -05:00
parent 715483c669
commit 5d1f322cd1
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
11 changed files with 789 additions and 7 deletions

View File

@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands';
import Config from './models/Config';
import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials';
import Groups from './models/Groups';
import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories';
@ -28,6 +29,7 @@ const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config();
const CredentialsAPI = new Credentials();
const CredentialTypesAPI = new CredentialTypes();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories();
@ -55,6 +57,7 @@ export {
ConfigAPI,
CredentialsAPI,
CredentialTypesAPI,
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,
InventoriesAPI,

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Groups extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/groups/';
}
}
export default Groups;

View File

@ -7,6 +7,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
this.baseUrl = '/api/v2/inventories/';
this.readAccessList = this.readAccessList.bind(this);
this.readHosts = this.readHosts.bind(this);
this.readGroups = this.readGroups.bind(this);
this.readGroupsOptions = this.readGroupsOptions.bind(this);
this.promoteGroup = this.promoteGroup.bind(this);
}
readAccessList(id, params) {
@ -18,6 +22,21 @@ class Inventories extends InstanceGroupsMixin(Base) {
readHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/hosts/`, { params });
}
readGroups(id, params) {
return this.http.get(`${this.baseUrl}${id}/groups/`, { params });
}
readGroupsOptions(id) {
return this.http.options(`${this.baseUrl}${id}/groups/`);
}
promoteGroup(inventoryId, groupId) {
return this.http.post(`${this.baseUrl}${inventoryId}/groups/`, {
id: groupId,
disassociate: true,
});
}
}
export default Inventories;

View File

@ -110,6 +110,7 @@
--pf-c-modal-box__footer--PaddingRight: 20px;
--pf-c-modal-box__footer--PaddingBottom: 20px;
--pf-c-modal-box__footer--PaddingLeft: 20px;
--pf-c-modal-box__footer--MarginTop: 24px;
justify-content: flex-end;
}

View File

@ -0,0 +1,77 @@
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 {
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import { PencilAltIcon } from '@patternfly/react-icons';
import ActionButtonCell from '@components/ActionButtonCell';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import ListActionButton from '@components/ListActionButton';
import VerticalSeparator from '@components/VerticalSeparator';
function InventoryGroupItem({
i18n,
group,
inventoryId,
isSelected,
onSelect,
}) {
const labelId = `check-action-${group.id}`;
const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/detail`;
const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`;
return (
<DataListItem key={group.id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
id={`select-group-${group.id}`}
checked={isSelected}
onChange={onSelect}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<VerticalSeparator />
<Link to={`${detailUrl}`} id={labelId}>
<b>{group.name}</b>
</Link>
</DataListCell>,
<ActionButtonCell lastcolumn="true" key="action">
{group.summary_fields.user_capabilities.edit && (
<Tooltip content={i18n._(t`Edit Group`)} position="top">
<ListActionButton
variant="plain"
component={Link}
to={editUrl}
>
<PencilAltIcon />
</ListActionButton>
</Tooltip>
)}
</ActionButtonCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
InventoryGroupItem.propTypes = {
group: Group.isRequired,
inventoryId: oneOfType([number, string]).isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(InventoryGroupItem);

View File

@ -0,0 +1,52 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import InventoryGroupItem from './InventoryGroupItem';
describe('<InventoryGroupItem />', () => {
let wrapper;
const mockGroup = {
id: 2,
type: 'group',
name: 'foo',
inventory: 1,
summary_fields: {
user_capabilities: {
edit: true,
},
},
};
beforeEach(() => {
wrapper = mountWithContexts(
<InventoryGroupItem
group={mockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryGroupItem').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(
<InventoryGroupItem
group={copyMockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});

View File

@ -1,10 +1,259 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
import React, { useState, useEffect } from 'react';
import { TrashAltIcon } from '@patternfly/react-icons';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { InventoriesAPI, GroupsAPI } from '@api';
import { Button, Tooltip } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import DataListToolbar from '@components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '@components/PaginatedDataList';
import styled from 'styled-components';
import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
class InventoryGroups extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
const QS_CONFIG = getQSConfig('host', {
page: 1,
page_size: 20,
order_by: 'name',
});
const DeleteButton = styled(Button)`
padding: 5px 8px;
&:hover {
background-color: #d9534f;
color: white;
}
&[disabled] {
color: var(--pf-c-button--m-plain--Color);
pointer-events: initial;
cursor: not-allowed;
}
`;
function cannotDelete(item) {
return !item.summary_fields.user_capabilities.delete;
}
export default InventoryGroups;
const useModal = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
function toggleModal() {
setIsModalOpen(!isModalOpen);
}
return {
isModalOpen,
toggleModal,
};
};
function InventoryGroups({ i18n, location, match }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [groupCount, setGroupCount] = useState(0);
const [groups, setGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selected, setSelected] = useState([]);
const { isModalOpen, toggleModal } = useModal();
const inventoryId = match.params.id;
const fetchGroups = (id, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readGroups(id, params);
};
useEffect(() => {
async function fetchData() {
try {
const [
{
data: { count, results },
},
{
data: { actions: optionActions },
},
] = await Promise.all([
fetchGroups(inventoryId, location.search),
InventoriesAPI.readGroupsOptions(inventoryId),
]);
setGroups(results);
setGroupCount(count);
setActions(optionActions);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [inventoryId, location]);
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 renderTooltip = () => {
const itemsUnableToDelete = selected
.filter(cannotDelete)
.map(item => item.name)
.join(', ');
if (selected.some(cannotDelete)) {
return (
<div>
{i18n._(
t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}`
)}
</div>
);
}
if (selected.length) {
return i18n._(t`Delete`);
}
return i18n._(t`Select a row to delete`);
};
const promoteGroups = list => {
const promotePromises = Object.keys(list)
.filter(groupId => list[groupId] === 'promote')
.map(groupId => InventoriesAPI.promoteGroup(inventoryId, +groupId));
return Promise.all(promotePromises);
};
const deleteGroups = list => {
const deletePromises = Object.keys(list)
.filter(groupId => list[groupId] === 'delete')
.map(groupId => GroupsAPI.destroy(+groupId));
return Promise.all(deletePromises);
};
const handleDelete = async list => {
setIsLoading(true);
try {
await Promise.all([promoteGroups(list), deleteGroups(list)]);
} catch (error) {
setDeletionError(error);
} finally {
toggleModal();
setSelected([]);
try {
const {
data: { count, results },
} = await fetchGroups(inventoryId, location.search);
setGroups(results);
setGroupCount(count);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
};
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected =
selected.length > 0 && selected.length === groups.length;
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={groups}
itemCount={groupCount}
qsConfig={QS_CONFIG}
renderItem={item => (
<InventoryGroupItem
key={item.id}
group={item}
inventoryId={inventoryId}
isSelected={selected.some(row => row.id === item.id)}
onSelect={() => handleSelect(item)}
/>
)}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<Tooltip content={renderTooltip()} position="top" key="delete">
<div>
<DeleteButton
variant="plain"
aria-label={i18n._(t`Delete`)}
onClick={toggleModal}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
>
<TrashAltIcon />
</DeleteButton>
</div>
</Tooltip>,
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
/>
),
]}
/>
)}
emptyStateControls={
canAdd && (
<ToolbarAddButton
key="add"
linkTo={`/inventories/inventory/${inventoryId}/groups/add`}
/>
)
}
/>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete one or more groups.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
<InventoryGroupsDeleteModal
groups={selected}
isModalOpen={isModalOpen}
onClose={toggleModal}
onDelete={handleDelete}
/>
</>
);
}
export default withI18n()(withRouter(InventoryGroups));

View File

@ -0,0 +1,207 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter, Route } from 'react-router-dom';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { InventoriesAPI, GroupsAPI } from '@api';
import InventoryGroups from './InventoryGroups';
jest.mock('@api');
const mockGroups = [
{
id: 1,
type: 'group',
name: 'foo',
inventory: 1,
url: '/api/v2/groups/1',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 2,
type: 'group',
name: 'bar',
inventory: 1,
url: '/api/v2/groups/2',
summary_fields: {
user_capabilities: {
delete: true,
edit: true,
},
},
},
{
id: 3,
type: 'group',
name: 'baz',
inventory: 1,
url: '/api/v2/groups/3',
summary_fields: {
user_capabilities: {
delete: false,
edit: false,
},
},
},
];
describe('<InventoryGroups />', () => {
let wrapper;
beforeEach(async () => {
InventoriesAPI.readGroups.mockResolvedValue({
data: {
count: mockGroups.length,
results: mockGroups,
},
});
InventoriesAPI.readGroupsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(
<MemoryRouter initialEntries={['/inventories/inventory/3/groups']}>
<Route
path="/inventories/inventory/:id/groups"
component={() => <InventoryGroups />}
/>
</MemoryRouter>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders successfully', () => {
expect(wrapper.find('InventoryGroups').length).toBe(1);
});
test('should fetch groups from api and render them in the list', async () => {
expect(InventoriesAPI.readGroups).toHaveBeenCalled();
expect(wrapper.find('InventoryGroupItem').length).toBe(3);
});
test('should check and uncheck the row item', async () => {
expect(
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
).toBe(false);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
true
);
});
wrapper.update();
expect(
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
).toBe(true);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(
false
);
});
wrapper.update();
expect(
wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked
).toBe(false);
});
test('should check all row items when select all is checked', async () => {
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
});
wrapper.update();
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(true);
});
await act(async () => {
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
});
wrapper.update();
wrapper.find('PFDataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
});
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readGroupsOptions.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroups />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should show content error if groups are not successfully fetched from api', async () => {
InventoriesAPI.readGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
});
await waitForElement(
wrapper,
'InventoryGroupsDeleteModal',
el => el.props().isModalOpen === true
);
await act(async () => {
wrapper
.find('ModalBoxFooter Button[aria-label="Delete"]')
.invoke('onClick')();
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should show error modal when group is not successfully deleted from api', async () => {
GroupsAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/groups/1',
},
data: 'An error occurred',
},
})
);
await act(async () => {
wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')();
});
wrapper.update();
await act(async () => {
wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')();
});
await waitForElement(
wrapper,
'InventoryGroupsDeleteModal',
el => el.props().isModalOpen === true
);
await act(async () => {
wrapper
.find('ModalBoxFooter Button[aria-label="Delete"]')
.invoke('onClick')();
});
await waitForElement(wrapper, { title: 'Error!', variant: 'danger' });
await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
});
});

View File

@ -20,7 +20,7 @@ const mockHost = {
},
};
describe.only('<InventoryHostItem />', () => {
describe('<InventoryHostItem />', () => {
beforeEach(() => {
toggleHost = jest.fn();
});

View File

@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
import AlertModal from '@components/AlertModal';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button, Radio } from '@patternfly/react-core';
import styled from 'styled-components';
const ListItem = styled.div`
padding: 24px 1px;
dl {
display: flex;
font-weight: 600;
}
dt {
color: var(--pf-global--danger-color--100);
margin-right: 10px;
}
.pf-c-radio {
margin-top: 10px;
}
`;
const ContentWrapper = styled.div`
${ListItem} + ${ListItem} {
border-top-width: 1px;
border-top-style: solid;
border-top-color: #d7d7d7;
}
${ListItem}:last-child {
padding-bottom: 0;
}
`;
const InventoryGroupsDeleteModal = ({
onClose,
onDelete,
isModalOpen,
groups,
i18n,
}) => {
const [deleteList, setDeleteList] = useState([]);
useEffect(() => {
const groupIds = groups.reduce((obj, group) => {
if (group.total_groups > 0 || group.total_hosts > 0) {
return { ...obj, [group.id]: null };
}
return { ...obj, [group.id]: 'delete' };
}, {});
setDeleteList(groupIds);
}, [groups]);
const handleChange = (groupId, radioOption) => {
setDeleteList({ ...deleteList, [groupId]: radioOption });
};
const content = groups
.map(group => {
if (group.total_groups > 0 || group.total_hosts > 0) {
return (
<ListItem key={group.id}>
<dl>
<dt>{group.name}</dt>
<dd>
{i18n._(
t`(${group.total_groups} Groups and ${group.total_hosts} Hosts)`
)}
</dd>
</dl>
<Radio
key="radio-delete"
label={i18n._(t`Delete All Groups and Hosts`)}
id={`radio-delete-${group.id}`}
name={`radio-${group.id}`}
onChange={() => handleChange(group.id, 'delete')}
/>
<Radio
key="radio-promote"
label={i18n._(t`Promote Child Groups and Hosts`)}
id={`radio-promote-${group.id}`}
name={`radio-${group.id}`}
onChange={() => handleChange(group.id, 'promote')}
/>
</ListItem>
);
}
return (
<ListItem key={group.id}>
<dl>
<dt>{group.name}</dt>
<dd>{i18n._(t`(No Child Groups or Hosts)`)}</dd>
</dl>
</ListItem>
);
})
.reduce((array, el) => {
return array.concat(el);
}, []);
return ReactDOM.createPortal(
<AlertModal
isOpen={isModalOpen}
variant="danger"
title={
groups.length > 1 ? i18n._(t`Delete Groups`) : i18n._(t`Delete Group`)
}
onClose={onClose}
actions={[
<Button
aria-label={i18n._(t`Delete`)}
onClick={() => onDelete(deleteList)}
variant="danger"
key="delete"
isDisabled={Object.keys(deleteList).some(
group => deleteList[group] === null
)}
>
{i18n._(t`Delete`)}
</Button>,
<Button
aria-label={i18n._(t`Close`)}
onClick={onClose}
variant="secondary"
key="cancel"
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
{i18n._(
t`Are you sure you want to delete the ${
groups.length > 1 ? i18n._(t`groups`) : i18n._(t`group`)
} below?`
)}
<ContentWrapper>{content}</ContentWrapper>
</AlertModal>,
document.body
);
};
export default withI18n()(InventoryGroupsDeleteModal);

View File

@ -229,3 +229,23 @@ export const User = shape({
ldap_dn: string,
last_login: string,
});
export const Group = shape({
id: number.isRequired,
type: oneOf(['group']),
url: string,
related: shape({}),
summary_fields: shape({}),
created: string,
modified: string,
name: string.isRequired,
description: string,
inventory: number,
variables: string,
has_active_failures: bool,
total_hosts: number,
hosts_with_active_failures: number,
total_groups: number,
groups_with_active_failures: number,
has_inventory_sources: bool,
});