mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 01:17:37 -02:30
Add Inventory Groups list
This commit is contained in:
@@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands';
|
|||||||
import Config from './models/Config';
|
import Config from './models/Config';
|
||||||
import CredentialTypes from './models/CredentialTypes';
|
import CredentialTypes from './models/CredentialTypes';
|
||||||
import Credentials from './models/Credentials';
|
import Credentials from './models/Credentials';
|
||||||
|
import Groups from './models/Groups';
|
||||||
import Hosts from './models/Hosts';
|
import Hosts from './models/Hosts';
|
||||||
import InstanceGroups from './models/InstanceGroups';
|
import InstanceGroups from './models/InstanceGroups';
|
||||||
import Inventories from './models/Inventories';
|
import Inventories from './models/Inventories';
|
||||||
@@ -28,6 +29,7 @@ const AdHocCommandsAPI = new AdHocCommands();
|
|||||||
const ConfigAPI = new Config();
|
const ConfigAPI = new Config();
|
||||||
const CredentialsAPI = new Credentials();
|
const CredentialsAPI = new Credentials();
|
||||||
const CredentialTypesAPI = new CredentialTypes();
|
const CredentialTypesAPI = new CredentialTypes();
|
||||||
|
const GroupsAPI = new Groups();
|
||||||
const HostsAPI = new Hosts();
|
const HostsAPI = new Hosts();
|
||||||
const InstanceGroupsAPI = new InstanceGroups();
|
const InstanceGroupsAPI = new InstanceGroups();
|
||||||
const InventoriesAPI = new Inventories();
|
const InventoriesAPI = new Inventories();
|
||||||
@@ -55,6 +57,7 @@ export {
|
|||||||
ConfigAPI,
|
ConfigAPI,
|
||||||
CredentialsAPI,
|
CredentialsAPI,
|
||||||
CredentialTypesAPI,
|
CredentialTypesAPI,
|
||||||
|
GroupsAPI,
|
||||||
HostsAPI,
|
HostsAPI,
|
||||||
InstanceGroupsAPI,
|
InstanceGroupsAPI,
|
||||||
InventoriesAPI,
|
InventoriesAPI,
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/Groups.js
Normal file
10
awx/ui_next/src/api/models/Groups.js
Normal 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;
|
||||||
@@ -7,6 +7,10 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
|||||||
this.baseUrl = '/api/v2/inventories/';
|
this.baseUrl = '/api/v2/inventories/';
|
||||||
|
|
||||||
this.readAccessList = this.readAccessList.bind(this);
|
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) {
|
readAccessList(id, params) {
|
||||||
@@ -18,6 +22,21 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
|||||||
readHosts(id, params) {
|
readHosts(id, params) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/hosts/`, { 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;
|
export default Inventories;
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
--pf-c-modal-box__footer--PaddingRight: 20px;
|
--pf-c-modal-box__footer--PaddingRight: 20px;
|
||||||
--pf-c-modal-box__footer--PaddingBottom: 20px;
|
--pf-c-modal-box__footer--PaddingBottom: 20px;
|
||||||
--pf-c-modal-box__footer--PaddingLeft: 20px;
|
--pf-c-modal-box__footer--PaddingLeft: 20px;
|
||||||
|
--pf-c-modal-box__footer--MarginTop: 24px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,259 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { CardBody } from '@patternfly/react-core';
|
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 {
|
const QS_CONFIG = getQSConfig('host', {
|
||||||
render() {
|
page: 1,
|
||||||
return <CardBody>Coming soon :)</CardBody>;
|
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));
|
||||||
|
|||||||
@@ -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')();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ const mockHost = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe.only('<InventoryHostItem />', () => {
|
describe('<InventoryHostItem />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
toggleHost = jest.fn();
|
toggleHost = jest.fn();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -229,3 +229,23 @@ export const User = shape({
|
|||||||
ldap_dn: string,
|
ldap_dn: string,
|
||||||
last_login: 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,
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user