mirror of
https://github.com/ansible/awx.git
synced 2026-03-01 08:48:46 -03:30
Merge pull request #6221 from marshmalien/6150-disassociate-modal
Add disassociate inventory group host button Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -6,11 +6,19 @@ class Groups extends Base {
|
|||||||
this.baseUrl = '/api/v2/groups/';
|
this.baseUrl = '/api/v2/groups/';
|
||||||
|
|
||||||
this.readAllHosts = this.readAllHosts.bind(this);
|
this.readAllHosts = this.readAllHosts.bind(this);
|
||||||
|
this.disassociateHost = this.disassociateHost.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
readAllHosts(id, params) {
|
readAllHosts(id, params) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params });
|
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disassociateHost(id, host) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/hosts/`, {
|
||||||
|
id: host.id,
|
||||||
|
disassociate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Groups;
|
export default Groups;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { arrayOf, func, object, string } from 'prop-types';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Button, Tooltip } from '@patternfly/react-core';
|
||||||
|
import AlertModal from '@components/AlertModal';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const ModalNote = styled.div`
|
||||||
|
margin-bottom: var(--pf-global--spacer--xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function DisassociateButton({
|
||||||
|
i18n,
|
||||||
|
itemsToDisassociate = [],
|
||||||
|
modalNote = '',
|
||||||
|
modalTitle = i18n._(t`Disassociate?`),
|
||||||
|
onDisassociate,
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
function handleDisassociate() {
|
||||||
|
onDisassociate();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cannotDisassociate(item) {
|
||||||
|
return !item.summary_fields.user_capabilities.delete;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTooltip() {
|
||||||
|
const itemsUnableToDisassociate = itemsToDisassociate
|
||||||
|
.filter(cannotDisassociate)
|
||||||
|
.map(item => item.name)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
if (itemsToDisassociate.some(cannotDisassociate)) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{i18n._(
|
||||||
|
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (itemsToDisassociate.length) {
|
||||||
|
return i18n._(t`Disassociate`);
|
||||||
|
}
|
||||||
|
return i18n._(t`Select a row to disassociate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled =
|
||||||
|
itemsToDisassociate.length === 0 ||
|
||||||
|
itemsToDisassociate.some(cannotDisassociate);
|
||||||
|
|
||||||
|
// NOTE: Once PF supports tooltips on disabled elements,
|
||||||
|
// we can delete the extra <div> around the <DeleteButton> below.
|
||||||
|
// See: https://github.com/patternfly/patternfly-react/issues/1894
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip content={renderTooltip()} position="top">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
aria-label={i18n._(t`Disassociate`)}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
{i18n._(t`Disassociate`)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
title={modalTitle}
|
||||||
|
variant="warning"
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="disassociate"
|
||||||
|
variant="danger"
|
||||||
|
aria-label={i18n._(t`confirm disassociate`)}
|
||||||
|
onClick={handleDisassociate}
|
||||||
|
>
|
||||||
|
{i18n._(t`Disassociate`)}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="cancel"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Cancel`)}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{modalNote && <ModalNote>{modalNote}</ModalNote>}
|
||||||
|
|
||||||
|
<div>{i18n._(t`This action will disassociate the following:`)}</div>
|
||||||
|
|
||||||
|
{itemsToDisassociate.map(item => (
|
||||||
|
<span key={item.id}>
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<br />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DisassociateButton.propTypes = {
|
||||||
|
itemsToDisassociate: arrayOf(object),
|
||||||
|
modalNote: string,
|
||||||
|
modalTitle: string,
|
||||||
|
onDisassociate: func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(DisassociateButton);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import DisassociateButton from './DisassociateButton';
|
||||||
|
|
||||||
|
describe('<DisassociateButton />', () => {
|
||||||
|
describe('User has disassociate permissions', () => {
|
||||||
|
let wrapper;
|
||||||
|
const handleDisassociate = jest.fn();
|
||||||
|
const mockHosts = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
delete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'bar',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
delete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<DisassociateButton
|
||||||
|
onDisassociate={handleDisassociate}
|
||||||
|
itemsToDisassociate={mockHosts}
|
||||||
|
modalNote="custom note"
|
||||||
|
modalTitle="custom title"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render button', () => {
|
||||||
|
expect(wrapper.find('button')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('button').text()).toEqual('Disassociate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open confirmation modal', () => {
|
||||||
|
wrapper.find('button').simulate('click');
|
||||||
|
expect(wrapper.find('AlertModal')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel button should close confirmation modal', () => {
|
||||||
|
expect(wrapper.find('AlertModal')).toHaveLength(1);
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').simulate('click');
|
||||||
|
expect(wrapper.find('AlertModal')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render expected modal content', () => {
|
||||||
|
wrapper.find('button').simulate('click');
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('AlertModal')
|
||||||
|
.containsMatchingElement(<div>custom note</div>)
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('AlertModal')
|
||||||
|
.containsMatchingElement(
|
||||||
|
<div>This action will disassociate the following:</div>
|
||||||
|
)
|
||||||
|
).toEqual(true);
|
||||||
|
expect(wrapper.find('Title').text()).toEqual('custom title');
|
||||||
|
wrapper.find('button[aria-label="Close"]').simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disassociate button should call handleDisassociate on click', () => {
|
||||||
|
wrapper.find('button').simulate('click');
|
||||||
|
expect(handleDisassociate).toHaveBeenCalledTimes(0);
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="confirm disassociate"]')
|
||||||
|
.simulate('click');
|
||||||
|
expect(handleDisassociate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User does not have disassociate permissions', () => {
|
||||||
|
const readOnlyHost = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'foo',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
delete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('should disable button when no delete permissions', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<DisassociateButton
|
||||||
|
onDisassociate={() => {}}
|
||||||
|
itemsToDelete={readOnlyHost}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('button[disabled]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,10 +7,12 @@ import { GroupsAPI, InventoriesAPI } from '@api';
|
|||||||
|
|
||||||
import AlertModal from '@components/AlertModal';
|
import AlertModal from '@components/AlertModal';
|
||||||
import DataListToolbar from '@components/DataListToolbar';
|
import DataListToolbar from '@components/DataListToolbar';
|
||||||
|
import ErrorDetail from '@components/ErrorDetail';
|
||||||
import PaginatedDataList from '@components/PaginatedDataList';
|
import PaginatedDataList from '@components/PaginatedDataList';
|
||||||
import useRequest from '@util/useRequest';
|
import useRequest, { useDeleteItems } from '@util/useRequest';
|
||||||
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
|
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
|
||||||
import AddHostDropdown from './AddHostDropdown';
|
import AddHostDropdown from './AddHostDropdown';
|
||||||
|
import DisassociateButton from './DisassociateButton';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('host', {
|
const QS_CONFIG = getQSConfig('host', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -67,6 +69,30 @@ function InventoryGroupHostList({ i18n }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAllSelected = selected.length > 0 && selected.length === hosts.length;
|
const isAllSelected = selected.length > 0 && selected.length === hosts.length;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading: isDisassociateLoading,
|
||||||
|
deleteItems: disassociateHosts,
|
||||||
|
deletionError: disassociateError,
|
||||||
|
clearDeletionError: clearDisassociateError,
|
||||||
|
} = useDeleteItems(
|
||||||
|
useCallback(async () => {
|
||||||
|
return Promise.all(
|
||||||
|
selected.map(host => GroupsAPI.disassociateHost(groupId, host))
|
||||||
|
);
|
||||||
|
}, [groupId, selected]),
|
||||||
|
{
|
||||||
|
qsConfig: QS_CONFIG,
|
||||||
|
allItemsSelected: isAllSelected,
|
||||||
|
fetchItems: fetchHosts,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDisassociate = async () => {
|
||||||
|
await disassociateHosts();
|
||||||
|
setSelected([]);
|
||||||
|
};
|
||||||
|
|
||||||
const canAdd =
|
const canAdd =
|
||||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||||
const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`;
|
const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`;
|
||||||
@@ -75,7 +101,7 @@ function InventoryGroupHostList({ i18n }) {
|
|||||||
<>
|
<>
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading}
|
hasContentLoading={isLoading || isDisassociateLoading}
|
||||||
items={hosts}
|
items={hosts}
|
||||||
itemCount={hostCount}
|
itemCount={hostCount}
|
||||||
pluralizedItemName={i18n._(t`Hosts`)}
|
pluralizedItemName={i18n._(t`Hosts`)}
|
||||||
@@ -113,12 +139,23 @@ function InventoryGroupHostList({ i18n }) {
|
|||||||
...(canAdd
|
...(canAdd
|
||||||
? [
|
? [
|
||||||
<AddHostDropdown
|
<AddHostDropdown
|
||||||
|
key="associate"
|
||||||
onAddExisting={() => setIsModalOpen(true)}
|
onAddExisting={() => setIsModalOpen(true)}
|
||||||
onAddNew={() => history.push(addFormUrl)}
|
onAddNew={() => history.push(addFormUrl)}
|
||||||
/>,
|
/>,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
// TODO HOST DISASSOCIATE BUTTON
|
<DisassociateButton
|
||||||
|
key="disassociate"
|
||||||
|
onDisassociate={handleDisassociate}
|
||||||
|
itemsToDisassociate={selected}
|
||||||
|
modalTitle={i18n._(t`Disassociate host from group?`)}
|
||||||
|
modalNote={i18n._(t`
|
||||||
|
Note that only hosts directly in this group can
|
||||||
|
be disassociated. Hosts in sub-groups must be disassociated
|
||||||
|
directly from the sub-group level that they belong.
|
||||||
|
`)}
|
||||||
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -141,9 +178,6 @@ function InventoryGroupHostList({ i18n }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* DISASSOCIATE HOST MODAL PLACEHOLDER */}
|
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
@@ -155,6 +189,17 @@ function InventoryGroupHostList({ i18n }) {
|
|||||||
{i18n._(t`Host Select Modal`)}
|
{i18n._(t`Host Select Modal`)}
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
|
{disassociateError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={disassociateError}
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={clearDisassociateError}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to disassociate one or more hosts.`)}
|
||||||
|
<ErrorDetail error={disassociateError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,45 @@ describe('<InventoryGroupHostList />', () => {
|
|||||||
expect(wrapper.find('AddHostDropdown').length).toBe(0);
|
expect(wrapper.find('AddHostDropdown').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('expected api calls are made for multi-delete', async () => {
|
||||||
|
expect(GroupsAPI.disassociateHost).toHaveBeenCalledTimes(0);
|
||||||
|
expect(GroupsAPI.readAllHosts).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
wrapper.find('button[aria-label="Disassociate"]').simulate('click');
|
||||||
|
expect(wrapper.find('AlertModal Title').text()).toEqual(
|
||||||
|
'Disassociate host from group?'
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="confirm disassociate"]')
|
||||||
|
.simulate('click');
|
||||||
|
});
|
||||||
|
expect(GroupsAPI.disassociateHost).toHaveBeenCalledTimes(3);
|
||||||
|
expect(GroupsAPI.readAllHosts).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show error modal for failed disassociation', async () => {
|
||||||
|
GroupsAPI.disassociateHost.mockRejectedValue(new Error());
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
wrapper.find('button[aria-label="Disassociate"]').simulate('click');
|
||||||
|
expect(wrapper.find('AlertModal Title').text()).toEqual(
|
||||||
|
'Disassociate host from group?'
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="confirm disassociate"]')
|
||||||
|
.simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('should show associate host modal when adding an existing host', () => {
|
test('should show associate host modal when adding an existing host', () => {
|
||||||
const dropdownToggle = wrapper.find(
|
const dropdownToggle = wrapper.find(
|
||||||
'DropdownToggle button[aria-label="add host"]'
|
'DropdownToggle button[aria-label="add host"]'
|
||||||
|
|||||||
Reference in New Issue
Block a user