diff --git a/awx/ui_next/src/api/models/Groups.js b/awx/ui_next/src/api/models/Groups.js index 4c19a44572..92acd3af40 100644 --- a/awx/ui_next/src/api/models/Groups.js +++ b/awx/ui_next/src/api/models/Groups.js @@ -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; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx new file mode 100644 index 0000000000..fdcddfb7e3 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.jsx @@ -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 ( +
+ {i18n._( + t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}` + )} +
+ ); + } + 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
around the below. + // See: https://github.com/patternfly/patternfly-react/issues/1894 + return ( + <> + +
+ +
+
+ + {isOpen && ( + setIsOpen(false)} + actions={[ + , + , + ]} + > + {modalNote && {modalNote}} + +
{i18n._(t`This action will disassociate the following:`)}
+ + {itemsToDisassociate.map(item => ( + + {item.name} +
+
+ ))} +
+ )} + + ); +} + +DisassociateButton.propTypes = { + itemsToDisassociate: arrayOf(object), + modalNote: string, + modalTitle: string, + onDisassociate: func.isRequired, +}; + +export default withI18n()(DisassociateButton); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx new file mode 100644 index 0000000000..06e6eedbe7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/DisassociateButton.test.jsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import DisassociateButton from './DisassociateButton'; + +describe('', () => { + 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( + + ); + }); + + 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(
custom note
) + ).toEqual(true); + expect( + wrapper + .find('AlertModal') + .containsMatchingElement( +
This action will disassociate the following:
+ ) + ).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( + {}} + itemsToDelete={readOnlyHost} + /> + ); + expect(wrapper.find('button[disabled]')).toHaveLength(1); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx index 237c9e5c93..1f4ce9d8b9 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.jsx @@ -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 }) { <> setIsModalOpen(true)} onAddNew={() => history.push(addFormUrl)} />, ] : []), - // TODO HOST DISASSOCIATE BUTTON + , ]} /> )} @@ -141,9 +178,6 @@ function InventoryGroupHostList({ i18n }) { ) } /> - - {/* DISASSOCIATE HOST MODAL PLACEHOLDER */} - {isModalOpen && ( )} + {disassociateError && ( + + {i18n._(t`Failed to disassociate one or more hosts.`)} + + + )} ); } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx index 8345964e40..f0a436a9d1 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.jsx @@ -108,6 +108,45 @@ describe('', () => { 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"]'