mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 02:50:02 -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:
commit
01d77d5407
@ -6,11 +6,19 @@ class Groups extends Base {
|
||||
this.baseUrl = '/api/v2/groups/';
|
||||
|
||||
this.readAllHosts = this.readAllHosts.bind(this);
|
||||
this.disassociateHost = this.disassociateHost.bind(this);
|
||||
}
|
||||
|
||||
readAllHosts(id, 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;
|
||||
|
||||
@ -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 DataListToolbar from '@components/DataListToolbar';
|
||||
import ErrorDetail from '@components/ErrorDetail';
|
||||
import PaginatedDataList from '@components/PaginatedDataList';
|
||||
import useRequest from '@util/useRequest';
|
||||
import useRequest, { useDeleteItems } from '@util/useRequest';
|
||||
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
|
||||
import AddHostDropdown from './AddHostDropdown';
|
||||
import DisassociateButton from './DisassociateButton';
|
||||
|
||||
const QS_CONFIG = getQSConfig('host', {
|
||||
page: 1,
|
||||
@ -67,6 +69,30 @@ function InventoryGroupHostList({ i18n }) {
|
||||
};
|
||||
|
||||
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 =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`;
|
||||
@ -75,7 +101,7 @@ function InventoryGroupHostList({ i18n }) {
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
hasContentLoading={isLoading || isDisassociateLoading}
|
||||
items={hosts}
|
||||
itemCount={hostCount}
|
||||
pluralizedItemName={i18n._(t`Hosts`)}
|
||||
@ -113,12 +139,23 @@ function InventoryGroupHostList({ i18n }) {
|
||||
...(canAdd
|
||||
? [
|
||||
<AddHostDropdown
|
||||
key="associate"
|
||||
onAddExisting={() => setIsModalOpen(true)}
|
||||
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 && (
|
||||
<AlertModal
|
||||
isOpen={isModalOpen}
|
||||
@ -155,6 +189,17 @@ function InventoryGroupHostList({ i18n }) {
|
||||
{i18n._(t`Host Select Modal`)}
|
||||
</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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const dropdownToggle = wrapper.find(
|
||||
'DropdownToggle button[aria-label="add host"]'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user